first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not ie <= 8
|
||||
@@ -0,0 +1,5 @@
|
||||
storybook-static
|
||||
**/*.stories.js
|
||||
|
||||
# Auto-generated
|
||||
src/components.d.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
/src/**/*.{ts,vue,scss,snap}
|
||||
/theme/src
|
||||
!dist
|
||||
|
||||
storybook-static
|
||||
.storybook
|
||||
|
||||
.browserslistrc
|
||||
jest.config.js
|
||||
vite.config.ts
|
||||
|
||||
*.md
|
||||
*.stories.js
|
||||
@@ -0,0 +1,51 @@
|
||||

|
||||
|
||||
# @n8n/design-system
|
||||
|
||||
A component system for [n8n](https://n8n.io) using Storybook to preview.
|
||||
|
||||
## Project setup
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```
|
||||
pnpm storybook
|
||||
```
|
||||
|
||||
### Build static pages
|
||||
|
||||
```
|
||||
pnpm build:storybook
|
||||
```
|
||||
|
||||
### Run your unit tests
|
||||
|
||||
```
|
||||
pnpm test:unit
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
|
||||
```
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
### Build css files
|
||||
|
||||
```
|
||||
pnpm build:theme
|
||||
```
|
||||
|
||||
### Monitor theme files and build any changes
|
||||
|
||||
```
|
||||
pnpm watch:theme
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../../biome.jsonc"],
|
||||
"formatter": {
|
||||
"ignore": ["theme/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"projectId": "Project:65f085d72c13e4e1154414db",
|
||||
"buildScriptName": "build:storybook"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import storybook from 'eslint-plugin-storybook';
|
||||
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { frontendConfig } from '@n8n/eslint-config/frontend';
|
||||
|
||||
export default defineConfig(
|
||||
frontendConfig,
|
||||
{
|
||||
rules: {
|
||||
'vue/no-undef-components': ['error', { ignorePatterns: ['N8nDropdownMenuItem'] }],
|
||||
|
||||
// TODO: Remove these
|
||||
'import-x/no-default-export': 'warn',
|
||||
'no-empty': 'warn',
|
||||
'no-prototype-builtins': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'warn',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/naming-convention': 'warn',
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/unbound-method': 'warn',
|
||||
'@typescript-eslint/restrict-template-expressions': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.stories.ts', 'src/**/*.vue', 'src/**/*.spec.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'warn',
|
||||
{
|
||||
selector: ['variable', 'property'],
|
||||
format: ['PascalCase', 'camelCase', 'UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/N8nFormInput/validators.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: ['property'],
|
||||
format: ['camelCase', 'UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
storybook.configs['flat/recommended'],
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "@n8n/design-system",
|
||||
"version": "2.11.0",
|
||||
"main": "src/index.ts",
|
||||
"import": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --dir=../storybook dev",
|
||||
"clean": "rimraf dist .turbo",
|
||||
"build": "vite build",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:watch": "vue-tsc --watch --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest",
|
||||
"chromatic": "chromatic",
|
||||
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
|
||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore",
|
||||
"lint": "eslint src --quiet",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"lint:styles": "stylelint \"src/**/*.{scss,sass,vue}\" --cache",
|
||||
"lint:styles:fix": "stylelint \"src/**/*.{scss,sass,vue}\" --fix --cache"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-config": "workspace:*",
|
||||
"@n8n/stylelint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@storybook/vue3-vite": "catalog:storybook",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@testing-library/user-event": "catalog:frontend",
|
||||
"@testing-library/vue": "catalog:frontend",
|
||||
"@types/lodash": "catalog:",
|
||||
"@types/markdown-it": "^13.0.9",
|
||||
"@types/markdown-it-emoji": "^2.0.2",
|
||||
"@types/markdown-it-link-attributes": "^3.0.5",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@vitejs/plugin-vue": "catalog:frontend",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vue/test-utils": "catalog:frontend",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"sass": "^1.71.1",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"unplugin-icons": "catalog:frontend",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vitest-mock-extended": "catalog:",
|
||||
"vue-tsc": "catalog:frontend"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.9.0",
|
||||
"@n8n/composables": "workspace:*",
|
||||
"@n8n/utils": "workspace:*",
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"clsx": "^2.1.1",
|
||||
"element-plus": "catalog:frontend",
|
||||
"emojibase-data": "^17.0.0",
|
||||
"is-emoji-supported": "^0.0.5",
|
||||
"lodash": "catalog:",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-emoji": "^2.0.2",
|
||||
"markdown-it-link-attributes": "^4.0.1",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"parse-diff": "^0.11.1",
|
||||
"reka-ui": "^2.5.0",
|
||||
"sanitize-html": "2.12.1",
|
||||
"vue": "catalog:frontend",
|
||||
"vue-boring-avatars": "^1.3.0",
|
||||
"vue-router": "catalog:frontend",
|
||||
"xss": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vueuse/core": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
// tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Button V2 Migration Codemod
|
||||
*
|
||||
* This script migrates N8nButton components from the legacy V1 API to the V2 API.
|
||||
*
|
||||
* Transformations:
|
||||
* - type="primary" → variant="solid"
|
||||
* - type="secondary" → variant="subtle"
|
||||
* - type="tertiary" → variant="subtle"
|
||||
* - type="danger" → variant="destructive"
|
||||
* - type="success" → variant="solid" class="n8n-button--success"
|
||||
* - type="warning" → variant="solid" class="n8n-button--warning"
|
||||
* - type="highlight" → variant="ghost" class="n8n-button--highlight"
|
||||
* - type="highlightFill" → variant="subtle" class="n8n-button--highlightFill"
|
||||
* - outline prop → variant="outline"
|
||||
* - text prop → variant="ghost"
|
||||
* - size="xmini"|"mini" → size="xsmall"
|
||||
* - square → iconOnly
|
||||
* - nativeType → type attribute
|
||||
* - block → style="width: 100%"
|
||||
* - element="a" → (removed, href determines element)
|
||||
* Usage:
|
||||
* node migrate-button-v2.mjs [--dry-run]
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Configuration
|
||||
const FRONTEND_ROOT = join(__dirname, '../../..');
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
// Mapping from legacy type to new variant
|
||||
const TYPE_TO_VARIANT = {
|
||||
primary: 'solid',
|
||||
secondary: 'subtle',
|
||||
tertiary: 'subtle',
|
||||
danger: 'destructive',
|
||||
};
|
||||
|
||||
// Legacy types that need override classes
|
||||
const LEGACY_TYPES_WITH_CLASSES = {
|
||||
success: { variant: 'solid', className: 'n8n-button--success' },
|
||||
warning: { variant: 'solid', className: 'n8n-button--warning' },
|
||||
highlight: { variant: 'ghost', className: 'n8n-button--highlight' },
|
||||
highlightFill: { variant: 'subtle', className: 'n8n-button--highlightFill' },
|
||||
};
|
||||
|
||||
// Size normalization
|
||||
const SIZE_MAP = {
|
||||
xmini: 'xsmall',
|
||||
mini: 'xsmall',
|
||||
};
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
filesScanned: 0,
|
||||
filesModified: 0,
|
||||
transformations: {
|
||||
typeToVariant: 0,
|
||||
legacyTypeWithClass: 0,
|
||||
outlineToVariant: 0,
|
||||
textToVariant: 0,
|
||||
sizeNormalized: 0,
|
||||
squareToIconOnly: 0,
|
||||
nativeTypeToType: 0,
|
||||
blockToStyle: 0,
|
||||
elementRemoved: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all .vue files recursively
|
||||
*/
|
||||
function findVueFiles(dir, files = []) {
|
||||
const entries = readdirSync(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
|
||||
// Skip node_modules and hidden directories
|
||||
if (entry === 'node_modules' || entry.startsWith('.')) continue;
|
||||
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
findVueFiles(fullPath, files);
|
||||
} else if (entry.endsWith('.vue')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single N8nButton tag
|
||||
*/
|
||||
function transformButtonTag(fullMatch, tagContent, selfClosing, content, closingTag) {
|
||||
let modified = false;
|
||||
const changes = [];
|
||||
|
||||
// Track what we need to add
|
||||
let newVariant = null;
|
||||
let addClass = null;
|
||||
let addStyle = null;
|
||||
|
||||
// Parse current attributes (handles both static and v-bind shorthand :prop)
|
||||
const hasType = /\btype=["']([^"']+)["']/.exec(tagContent);
|
||||
const hasVariant = /\bvariant=["']/.test(tagContent);
|
||||
const hasOutline = /\b:?outline(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
|
||||
const hasText = /\b:?text(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
|
||||
const hasSize = /\b:?size=["']([^"']+)["']/.exec(tagContent);
|
||||
const hasSquare = /\b:?square(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
|
||||
const hasNativeType = /\b:?nativeType=["']([^"']+)["']/.exec(tagContent);
|
||||
const hasBlock = /\b:?block(?:=["']true["'])?(?=\s|\/?>|\s)/.test(tagContent);
|
||||
const hasElement = /\b:?element=["']([^"']+)["']/.exec(tagContent);
|
||||
const hasClass = /\bclass=["']([^"']+)["']/.exec(tagContent);
|
||||
const hasStyle = /\bstyle=["']([^"']+)["']/.exec(tagContent);
|
||||
|
||||
// Skip if already using variant (already migrated)
|
||||
if (hasVariant) {
|
||||
return fullMatch;
|
||||
}
|
||||
|
||||
// 1. Handle outline prop → variant="outline"
|
||||
if (hasOutline && !hasText) {
|
||||
newVariant = 'outline';
|
||||
tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, '');
|
||||
// Also remove type if present since outline takes precedence
|
||||
if (hasType) {
|
||||
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
|
||||
}
|
||||
changes.push('outline → variant="outline"');
|
||||
stats.transformations.outlineToVariant++;
|
||||
modified = true;
|
||||
}
|
||||
// 2. Handle text prop → variant="ghost"
|
||||
else if (hasText) {
|
||||
newVariant = 'ghost';
|
||||
tagContent = tagContent.replace(/\s*\b:?text(?:=["']true["'])?(?=\s|\/?>)/, '');
|
||||
// Also remove type and outline if present
|
||||
if (hasType) {
|
||||
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
|
||||
}
|
||||
if (hasOutline) {
|
||||
tagContent = tagContent.replace(/\s*\b:?outline(?:=["']true["'])?(?=\s|\/?>)/, '');
|
||||
}
|
||||
changes.push('text → variant="ghost"');
|
||||
stats.transformations.textToVariant++;
|
||||
modified = true;
|
||||
}
|
||||
// 3. Handle type prop
|
||||
else if (hasType) {
|
||||
const typeValue = hasType[1];
|
||||
|
||||
if (TYPE_TO_VARIANT[typeValue]) {
|
||||
// Direct mapping
|
||||
newVariant = TYPE_TO_VARIANT[typeValue];
|
||||
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
|
||||
changes.push(`type="${typeValue}" → variant="${newVariant}"`);
|
||||
stats.transformations.typeToVariant++;
|
||||
modified = true;
|
||||
} else if (LEGACY_TYPES_WITH_CLASSES[typeValue]) {
|
||||
// Legacy type with class override
|
||||
const mapping = LEGACY_TYPES_WITH_CLASSES[typeValue];
|
||||
newVariant = mapping.variant;
|
||||
addClass = mapping.className;
|
||||
tagContent = tagContent.replace(/\s*\btype=["'][^"']+["']/, '');
|
||||
changes.push(`type="${typeValue}" → variant="${newVariant}" + class="${addClass}"`);
|
||||
stats.transformations.legacyTypeWithClass++;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Handle size normalization
|
||||
if (hasSize && SIZE_MAP[hasSize[1]]) {
|
||||
const oldSize = hasSize[1];
|
||||
const newSize = SIZE_MAP[oldSize];
|
||||
tagContent = tagContent.replace(/\b:?size=["'][^"']+["']/, `size="${newSize}"`);
|
||||
changes.push(`size="${oldSize}" → size="${newSize}"`);
|
||||
stats.transformations.sizeNormalized++;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 5. Handle square → iconOnly
|
||||
if (hasSquare) {
|
||||
tagContent = tagContent.replace(/\s*\b:?square(?:=["']true["'])?(?=\s|\/?>)/, '');
|
||||
// Add iconOnly attribute
|
||||
tagContent = tagContent.replace(
|
||||
/(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/,
|
||||
'$1 iconOnly',
|
||||
);
|
||||
changes.push('square → iconOnly');
|
||||
stats.transformations.squareToIconOnly++;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 6. Handle nativeType → type
|
||||
if (hasNativeType) {
|
||||
const nativeTypeValue = hasNativeType[1];
|
||||
tagContent = tagContent.replace(/\s*\b:?nativeType=["'][^"']+["']/, '');
|
||||
tagContent = tagContent.replace(
|
||||
/(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/,
|
||||
`$1 type="${nativeTypeValue}"`,
|
||||
);
|
||||
changes.push(`nativeType="${nativeTypeValue}" → type="${nativeTypeValue}"`);
|
||||
stats.transformations.nativeTypeToType++;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 7. Handle block → style="width: 100%"
|
||||
if (hasBlock) {
|
||||
tagContent = tagContent.replace(/\s*\b:?block(?:=["']true["'])?(?=\s|\/?>)/, '');
|
||||
addStyle = 'width: 100%';
|
||||
changes.push('block → style="width: 100%"');
|
||||
stats.transformations.blockToStyle++;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// 8. Handle element="a" → remove (href determines element)
|
||||
if (hasElement) {
|
||||
tagContent = tagContent.replace(/\s*\b:?element=["'][^"']+["']/, '');
|
||||
changes.push('element removed (href determines element)');
|
||||
stats.transformations.elementRemoved++;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (!modified) {
|
||||
return fullMatch;
|
||||
}
|
||||
|
||||
// Now apply the collected changes
|
||||
const BUTTON_TAG_PATTERN = /(N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton)/;
|
||||
|
||||
// Add variant attribute
|
||||
if (newVariant) {
|
||||
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 variant="${newVariant}"`);
|
||||
}
|
||||
|
||||
// Merge class attribute
|
||||
if (addClass) {
|
||||
if (hasClass) {
|
||||
// Merge with existing class
|
||||
tagContent = tagContent.replace(/\bclass=["']([^"']+)["']/, `class="$1 ${addClass}"`);
|
||||
} else {
|
||||
// Add new class attribute
|
||||
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 class="${addClass}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge style attribute
|
||||
if (addStyle) {
|
||||
if (hasStyle) {
|
||||
// Merge with existing style
|
||||
const existingStyle = hasStyle[1].trim();
|
||||
const separator = existingStyle.endsWith(';') ? ' ' : '; ';
|
||||
tagContent = tagContent.replace(
|
||||
/\bstyle=["']([^"']+)["']/,
|
||||
`style="$1${separator}${addStyle}"`,
|
||||
);
|
||||
} else {
|
||||
// Add new style attribute
|
||||
tagContent = tagContent.replace(BUTTON_TAG_PATTERN, `$1 style="${addStyle}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build the new tag
|
||||
let result;
|
||||
if (selfClosing) {
|
||||
result = `<${tagContent.trim()} />`;
|
||||
} else {
|
||||
result = `<${tagContent.trim()}>${content || ''}${closingTag}`;
|
||||
}
|
||||
|
||||
// Log the transformation
|
||||
console.log(` ${changes.join(', ')}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform all N8nButton usages in a file
|
||||
*/
|
||||
function transformFile(filePath) {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
stats.filesScanned++;
|
||||
|
||||
// Find template section - use greedy match ([\s\S]*) to get the outermost </template>
|
||||
// Vue SFC files may have nested <template> tags for slots, so we need the last closing tag
|
||||
const templateMatch = /<template[^>]*>([\s\S]*)<\/template>/i.exec(content);
|
||||
if (!templateMatch) {
|
||||
return { modified: false };
|
||||
}
|
||||
|
||||
const templateStart = templateMatch.index;
|
||||
const templateContent = templateMatch[1];
|
||||
|
||||
// Match button components (both self-closing and with content)
|
||||
// Includes: N8nButton, n8n-button, N8nIconButton, n8n-icon-button, IconButton
|
||||
const BUTTON_TAGS = 'N8nButton|n8n-button|N8nIconButton|n8n-icon-button|IconButton';
|
||||
|
||||
let newTemplateContent = templateContent;
|
||||
let hasChanges = false;
|
||||
|
||||
// Process self-closing buttons first
|
||||
// Pattern matches: <TAG + attributes (no unquoted >) + />
|
||||
// This avoids matching non-self-closing tags like <N8nButton ...>content</N8nButton>
|
||||
newTemplateContent = newTemplateContent.replace(
|
||||
new RegExp(`<(${BUTTON_TAGS})((?:[^>"]|"[^"]*")*)\\s*\\/>`, 'gi'),
|
||||
(match, tagName, attrs) => {
|
||||
// Skip if no attributes
|
||||
if (!attrs || !attrs.trim()) return match;
|
||||
const result = transformButtonTag(match, `${tagName} ${attrs.trim()}`, true, null, null);
|
||||
if (result !== match) hasChanges = true;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// Process buttons with content (non-self-closing)
|
||||
// Pattern matches: <TAG + attrs (not ending with /) + > + content + </TAG>
|
||||
// The attrs pattern uses negative lookahead to ensure / is not followed by >
|
||||
newTemplateContent = newTemplateContent.replace(
|
||||
new RegExp(
|
||||
`<(${BUTTON_TAGS})((?:[^>"/]|"[^"]*"|/(?!>))*)>([\\s\\S]*?)<\\/(${BUTTON_TAGS})>`,
|
||||
'gi',
|
||||
),
|
||||
(match, tagName, attrs, content, closeTag) => {
|
||||
const result = transformButtonTag(
|
||||
match,
|
||||
`${tagName}${attrs || ''}`,
|
||||
false,
|
||||
content,
|
||||
`</${closeTag}>`,
|
||||
);
|
||||
if (result !== match) hasChanges = true;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
if (!hasChanges) {
|
||||
return { modified: false };
|
||||
}
|
||||
|
||||
// Reconstruct the file
|
||||
const newContent =
|
||||
content.slice(0, templateStart) +
|
||||
'<template>' +
|
||||
newTemplateContent +
|
||||
'</template>' +
|
||||
content.slice(templateStart + templateMatch[0].length);
|
||||
|
||||
return { modified: true, content: newContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
console.log('Button V2 Migration Codemod');
|
||||
console.log('===========================');
|
||||
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (no files will be modified)' : 'LIVE'}`);
|
||||
console.log(`Scanning: ${FRONTEND_ROOT}`);
|
||||
console.log('');
|
||||
|
||||
const vueFiles = findVueFiles(FRONTEND_ROOT);
|
||||
console.log(`Found ${vueFiles.length} Vue files\n`);
|
||||
|
||||
for (const filePath of vueFiles) {
|
||||
const relativePath = relative(FRONTEND_ROOT, filePath);
|
||||
|
||||
try {
|
||||
const result = transformFile(filePath);
|
||||
|
||||
if (result.modified) {
|
||||
console.log(`\nModified: ${relativePath}`);
|
||||
stats.filesModified++;
|
||||
|
||||
if (!DRY_RUN) {
|
||||
writeFileSync(filePath, result.content, 'utf-8');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${relativePath}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log('\n===========================');
|
||||
console.log('Summary');
|
||||
console.log('===========================');
|
||||
console.log(`Files scanned: ${stats.filesScanned}`);
|
||||
console.log(`Files modified: ${stats.filesModified}`);
|
||||
console.log('');
|
||||
console.log('Transformations:');
|
||||
console.log(` type → variant: ${stats.transformations.typeToVariant}`);
|
||||
console.log(` legacy type + class: ${stats.transformations.legacyTypeWithClass}`);
|
||||
console.log(` outline → variant: ${stats.transformations.outlineToVariant}`);
|
||||
console.log(` text → variant: ${stats.transformations.textToVariant}`);
|
||||
console.log(` size normalized: ${stats.transformations.sizeNormalized}`);
|
||||
console.log(` square → iconOnly: ${stats.transformations.squareToIconOnly}`);
|
||||
console.log(` nativeType → type: ${stats.transformations.nativeTypeToType}`);
|
||||
console.log(` block → style: ${stats.transformations.blockToStyle}`);
|
||||
console.log(` element removed: ${stats.transformations.elementRemoved}`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('\nDry run complete. No files were modified.');
|
||||
console.log('Run without --dry-run to apply changes.');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,44 @@
|
||||
# Component specification
|
||||
|
||||
Copy this file next to the template and call it `component-<component-name-kebap-case>.md`.
|
||||
|
||||
This component specification describes the public API of a component intended to be added to the component library of our design system.
|
||||
To be made available for review in a GitHub pull request before implementation.
|
||||
With two approvals, you can be quite sure that the API won't be challenged anymore on the implementation PR.
|
||||
|
||||
- **Component Name:** N8nCheckbox
|
||||
- **Element+ Component:** [ElCheckbox](https://element-plus.org/en-US/component/checkbox)
|
||||
- **Reka UI Component:** [Checkbox](https://reka-ui.com/docs/components/checkbox)
|
||||
- **Nuxt UI Component:** [Checkbox](https://ui.nuxt.com/docs/components/checkbox)
|
||||
|
||||
^ Only list what exists in the external component libraries. Nuxt uses Reka under the hood. We use it as guideline on for the public API of our reka-ui-based components
|
||||
|
||||
## Public API Definition
|
||||
|
||||
**Props**
|
||||
|
||||
- `modelValue`: `boolean` | `default: false`
|
||||
- `label?`: `string` | provided via slot
|
||||
- `disabled?`: `boolean` | `default: false`
|
||||
- `tooltipText?`: `string`
|
||||
- `size?`: `'small' | 'medium'` | `default: 'medium'`
|
||||
|
||||
**Events**
|
||||
|
||||
- `update:modelValue(value: boolean)`
|
||||
- `change(value: boolean)`
|
||||
|
||||
**Slots**
|
||||
|
||||
- `label`: `{ label?: string | undefined; }`
|
||||
|
||||
**CSS Variables**
|
||||
|
||||
- `--checkbox--border-color`
|
||||
- `--checkbox--border-color--checked`
|
||||
|
||||
### Template usage example
|
||||
|
||||
```vue
|
||||
<N8nCheckbox size="medium" label="Subscribe to newsletter" />
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import { N8nPlugin } from '@n8n/design-system/plugin';
|
||||
|
||||
type Component = Parameters<typeof render>[0];
|
||||
type RenderOptions = Parameters<typeof render>[1];
|
||||
|
||||
export const createComponentRenderer = (component: Component) => (options: RenderOptions) => {
|
||||
const mergedOptions: RenderOptions = {
|
||||
...options,
|
||||
global: {
|
||||
...(options?.global ?? {}),
|
||||
stubs: {
|
||||
...(options?.global?.stubs ?? {}),
|
||||
},
|
||||
plugins: [N8nPlugin, ...(options?.global?.plugins ?? [])],
|
||||
},
|
||||
};
|
||||
return render(component, mergedOptions);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { configure } from '@testing-library/vue';
|
||||
import { config } from '@vue/test-utils';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
import { N8nPlugin } from '@n8n/design-system/plugin';
|
||||
|
||||
configure({ testIdAttribute: 'data-test-id' });
|
||||
|
||||
config.global.plugins = [N8nPlugin];
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
vi.fn().mockImplementation(() => ({
|
||||
disconnect: vi.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
}));
|
||||
|
||||
// Globally mock is-emoji-supported
|
||||
vi.mock('is-emoji-supported', () => ({
|
||||
isEmojiSupported: () => true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Fixes missing pointer APIs and defaultPrevented issues for jsdom + user-event
|
||||
*/
|
||||
beforeAll(() => {
|
||||
// Patch missing pointer APIs
|
||||
const elementProto = HTMLElement.prototype as HTMLElement & {
|
||||
hasPointerCapture?: (pointerId: number) => boolean;
|
||||
setPointerCapture?: (pointerId: number) => void;
|
||||
releasePointerCapture?: (pointerId: number) => void;
|
||||
};
|
||||
|
||||
if (!elementProto.hasPointerCapture) {
|
||||
Object.defineProperties(elementProto, {
|
||||
hasPointerCapture: {
|
||||
value: (_: number) => false,
|
||||
writable: true,
|
||||
},
|
||||
setPointerCapture: {
|
||||
value: (_: number) => {},
|
||||
writable: true,
|
||||
},
|
||||
releasePointerCapture: {
|
||||
value: (_: number) => {},
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Preserve originals
|
||||
const OriginalMouseEvent = window.MouseEvent;
|
||||
const OriginalPointerEvent = window.PointerEvent || window.MouseEvent;
|
||||
|
||||
// Patched MouseEvent
|
||||
class PatchedMouseEvent extends OriginalMouseEvent {
|
||||
constructor(type: string, eventInit?: MouseEventInit) {
|
||||
super(type, eventInit);
|
||||
Object.defineProperty(this, 'defaultPrevented', {
|
||||
get: () => false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Patched PointerEvent
|
||||
class PatchedPointerEvent extends OriginalPointerEvent {
|
||||
constructor(type: string, eventInit?: PointerEventInit) {
|
||||
super(type, eventInit);
|
||||
Object.defineProperty(this, 'defaultPrevented', {
|
||||
get: () => false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('MouseEvent', PatchedMouseEvent);
|
||||
vi.stubGlobal('PointerEvent', PatchedPointerEvent);
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import AssistantAvatar from './AssistantAvatar.vue';
|
||||
|
||||
describe('AskAssistantAvatar', () => {
|
||||
it('renders small avatar correctly', () => {
|
||||
const { container } = render(AssistantAvatar, {
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders mini avatar correctly', () => {
|
||||
const { container } = render(AssistantAvatar, {
|
||||
props: {
|
||||
size: 'mini',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import AssistantAvatar from './AssistantAvatar.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AssistantAvatar',
|
||||
component: AssistantAvatar,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
AssistantAvatar,
|
||||
},
|
||||
template: '<AssistantAvatar v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
||||
|
||||
export const Mini = Template.bind({});
|
||||
Mini.args = {
|
||||
size: 'mini',
|
||||
};
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
|
||||
withDefaults(defineProps<{ size?: 'small' | 'mini' }>(), {
|
||||
size: 'small',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[$style.container, $style[size]]">
|
||||
<AssistantIcon :size="size" theme="blank" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
background: var(--assistant--color--highlight-gradient);
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: var(--spacing--md);
|
||||
width: var(--spacing--md);
|
||||
}
|
||||
|
||||
.mini {
|
||||
height: var(--spacing--sm);
|
||||
width: var(--spacing--sm);
|
||||
}
|
||||
</style>
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskAssistantAvatar > renders mini avatar correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container mini"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AskAssistantAvatar > renders small avatar correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container small"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
width="10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AssistantAvatar from './AssistantAvatar.vue';
|
||||
|
||||
export default AssistantAvatar;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import AskAssistantButton from './AskAssistantButton.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AskAssistantButton',
|
||||
component: AskAssistantButton,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const methods = {
|
||||
onClick: action('click'),
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
AskAssistantButton,
|
||||
},
|
||||
template:
|
||||
'<div style="display: flex; height: 50px; width: 300px; align-items: center; justify-content: center"><AskAssistantButton v-bind="args" @click="onClick" /></div>',
|
||||
methods,
|
||||
});
|
||||
|
||||
export const Button = Template.bind({});
|
||||
|
||||
export const Notifications = Template.bind({});
|
||||
Notifications.args = {
|
||||
unreadCount: 1,
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import AskAssistantButton from './AskAssistantButton.vue';
|
||||
|
||||
describe('AskAssistantButton', () => {
|
||||
it('renders default button correctly', () => {
|
||||
const { container } = render(AskAssistantButton, {});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders button with unread messages correctly', () => {
|
||||
const { container } = render(AskAssistantButton, {
|
||||
props: {
|
||||
unreadCount: 3,
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
import AssistantText from '../AskAssistantText/AssistantText.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const hovering = ref(false);
|
||||
|
||||
const props = defineProps<{ unreadCount?: number; type?: 'assistant' | 'builder' }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [e: MouseEvent];
|
||||
}>();
|
||||
|
||||
const onClick = (e: MouseEvent) => emit('click', e);
|
||||
|
||||
function onMouseEnter() {
|
||||
hovering.value = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hovering.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="$style.button"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@click="onClick"
|
||||
>
|
||||
<div v-if="props.unreadCount" :class="$style.num">
|
||||
{{ props.unreadCount }}
|
||||
</div>
|
||||
<AssistantIcon v-else size="large" :theme="hovering ? 'blank' : 'default'" />
|
||||
<div v-show="hovering" :class="$style.text">
|
||||
<div>
|
||||
<AssistantText
|
||||
:text="
|
||||
type === 'builder'
|
||||
? t('assistantChat.builder.name')
|
||||
: t('askAssistantButton.askAssistant')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.button {
|
||||
border: var(--border);
|
||||
background: var(--color--foreground--tint-2);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
background: var(--assistant--color--highlight-gradient--reverse);
|
||||
|
||||
> div {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.num {
|
||||
color: var(--color--neutral-white);
|
||||
background: var(--assistant--color--highlight-gradient--reverse);
|
||||
border-radius: 50%;
|
||||
width: var(--spacing--sm);
|
||||
height: var(--spacing--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size--3xs);
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
width: 100px;
|
||||
right: 48px;
|
||||
}
|
||||
</style>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskAssistantButton > renders button with unread messages correctly 1`] = `
|
||||
<div>
|
||||
<button
|
||||
class="button"
|
||||
>
|
||||
<div
|
||||
class="num"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div
|
||||
class="text"
|
||||
style="display: none;"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text medium"
|
||||
>
|
||||
n8n AI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AskAssistantButton > renders default button correctly 1`] = `
|
||||
<div>
|
||||
<button
|
||||
class="button"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<div
|
||||
class="text"
|
||||
style="display: none;"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text medium"
|
||||
>
|
||||
n8n AI
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AskAssistantButton from './AskAssistantButton.vue';
|
||||
|
||||
export default AskAssistantButton;
|
||||
+1261
File diff suppressed because it is too large
Load Diff
+1509
File diff suppressed because it is too large
Load Diff
+884
@@ -0,0 +1,884 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, useCssModule, watch } from 'vue';
|
||||
|
||||
import MessageRating from './messages/MessageRating.vue';
|
||||
import MessageWrapper from './messages/MessageWrapper.vue';
|
||||
import ThinkingMessage from './messages/ThinkingMessage.vue';
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import type { ChatUI, RatingFeedback, WorkflowSuggestion } from '../../types/assistant';
|
||||
import { isTaskAbortedMessage, isToolMessage, isThinkingGroupMessage } from '../../types/assistant';
|
||||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
import AssistantText from '../AskAssistantText/AssistantText.vue';
|
||||
import InlineAskAssistantButton from '../InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||
import N8nButton from '../N8nButton';
|
||||
import N8nIconButton from '../N8nIconButton';
|
||||
import N8nPromptInput from '../N8nPromptInput';
|
||||
import N8nPromptInputSuggestions from '../N8nPromptInputSuggestions';
|
||||
import N8nScrollArea from '../N8nScrollArea/N8nScrollArea.vue';
|
||||
import { getSupportedMessageComponent } from './messages/helpers';
|
||||
|
||||
const { t } = useI18n();
|
||||
const $style = useCssModule();
|
||||
|
||||
interface Props {
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
messages?: ChatUI.AssistantMessage[];
|
||||
streaming?: boolean;
|
||||
disabled?: boolean;
|
||||
disabledTooltip?: string;
|
||||
loadingMessage?: string;
|
||||
sessionId?: string;
|
||||
inputPlaceholder?: string;
|
||||
scrollOnNewMessage?: boolean;
|
||||
showStop?: boolean;
|
||||
creditsQuota?: number;
|
||||
creditsRemaining?: number;
|
||||
showAskOwnerTooltip?: boolean;
|
||||
maxCharacterLength?: number;
|
||||
suggestions?: WorkflowSuggestion[];
|
||||
workflowId?: string;
|
||||
pruneTimeHours?: number;
|
||||
/** Custom message to show when all tools complete (instead of default "Workflow generated") */
|
||||
thinkingCompletionMessage?: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
stop: [];
|
||||
message: [string, string?, boolean?];
|
||||
codeReplace: [number];
|
||||
codeUndo: [number];
|
||||
feedback: [RatingFeedback];
|
||||
'upgrade-click': [];
|
||||
restore: [versionId: string];
|
||||
restoreConfirm: [versionId: string, messageId: string];
|
||||
restoreCancel: [];
|
||||
showVersion: [versionId: string];
|
||||
}>();
|
||||
|
||||
const onClose = () => emit('close');
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
user: () => ({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
}),
|
||||
messages: () => [],
|
||||
loadingMessage: undefined,
|
||||
sessionId: undefined,
|
||||
scrollOnNewMessage: false,
|
||||
maxCharacterLength: undefined,
|
||||
inputPlaceholder: undefined,
|
||||
});
|
||||
|
||||
function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
|
||||
return messages.map((msg, index) => ({
|
||||
...msg,
|
||||
id: msg.id || `msg-${index}`,
|
||||
read: msg.read ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
// filter out these messages so that tool collapsing works correctly
|
||||
function filterOutHiddenMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] {
|
||||
return messages.filter(
|
||||
(message) => Boolean(getSupportedMessageComponent(message.type)) || message.type === 'custom',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Set of tool message IDs whose "region" in the original (unfiltered) messages
|
||||
* contained a workflow-updated message. A region is a contiguous run of tool +
|
||||
* workflow-updated messages (any other type breaks the region).
|
||||
* This is needed because workflow-updated messages are filtered before grouping,
|
||||
* so the grouping function can't see them directly.
|
||||
*/
|
||||
function getToolIdsWithWorkflowUpdate(
|
||||
messages: ChatUI.AssistantMessage[],
|
||||
): Set<string | undefined> {
|
||||
const result = new Set<string | undefined>();
|
||||
let currentGroupToolIds: Array<string | undefined> = [];
|
||||
let hasWorkflowUpdate = false;
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'tool') {
|
||||
currentGroupToolIds.push(msg.id);
|
||||
} else if (msg.type === 'workflow-updated') {
|
||||
// Only count as a workflow update if tools already exist in this region.
|
||||
// This prevents naming-only updates (which arrive before tools) from
|
||||
// causing discovery tool groups to show "Workflow generated".
|
||||
if (currentGroupToolIds.length > 0) {
|
||||
hasWorkflowUpdate = true;
|
||||
}
|
||||
} else {
|
||||
// Group boundary — flush
|
||||
if (hasWorkflowUpdate) {
|
||||
for (const id of currentGroupToolIds) result.add(id);
|
||||
}
|
||||
currentGroupToolIds = [];
|
||||
hasWorkflowUpdate = false;
|
||||
}
|
||||
}
|
||||
// Flush final group
|
||||
if (hasWorkflowUpdate) {
|
||||
for (const id of currentGroupToolIds) result.add(id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupToolMessagesIntoThinking(
|
||||
messages: ChatUI.AssistantMessage[],
|
||||
options: {
|
||||
streaming?: boolean;
|
||||
loadingMessage?: string;
|
||||
thinkingCompletionMessage?: string;
|
||||
toolIdsWithWorkflowUpdate?: Set<string | undefined>;
|
||||
t: (key: string) => string;
|
||||
},
|
||||
): ChatUI.AssistantMessage[] {
|
||||
const result: ChatUI.AssistantMessage[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const currentMsg = messages[i];
|
||||
|
||||
// If it's not a tool message, add it as-is
|
||||
if (!isToolMessage(currentMsg)) {
|
||||
result.push(currentMsg);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect ALL consecutive tool messages
|
||||
const toolGroup: ChatUI.ToolMessage[] = [currentMsg];
|
||||
let j = i + 1;
|
||||
|
||||
while (j < messages.length) {
|
||||
const msg = messages[j];
|
||||
if (!isToolMessage(msg)) break;
|
||||
toolGroup.push(msg);
|
||||
j++;
|
||||
}
|
||||
|
||||
// Deduplicate tool messages by toolName, keeping the latest status for each unique tool type
|
||||
// This matches the original behavior where multiple calls to the same tool (e.g., get_node_details)
|
||||
// are collapsed into a single entry showing the most recent status
|
||||
const uniqueToolsMap = new Map<string, ChatUI.ToolMessage>();
|
||||
for (const tool of toolGroup) {
|
||||
// Group by toolName so multiple calls to the same tool are collapsed
|
||||
const key = tool.toolName;
|
||||
// Later messages in the array have the most recent status, so they overwrite earlier ones
|
||||
uniqueToolsMap.set(key, tool);
|
||||
}
|
||||
const uniqueTools = Array.from(uniqueToolsMap.values());
|
||||
|
||||
// Check if there are any more tool messages after this group
|
||||
let hasMoreToolsAfter = false;
|
||||
for (let k = j; k < messages.length; k++) {
|
||||
if (isToolMessage(messages[k])) {
|
||||
hasMoreToolsAfter = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const isLastToolGroup = !hasMoreToolsAfter;
|
||||
const allToolsCompleted = uniqueTools.every((m) => m.status === 'completed');
|
||||
const hasRunningTool = uniqueTools.some((m) => m.status === 'running');
|
||||
|
||||
// A group is "active" (still receiving stream data) only if it's the last tool group
|
||||
// AND there are no messages after it. If there are messages after it (text response,
|
||||
// user message for next turn), this group is completed — even if streaming is active
|
||||
// for a newer turn.
|
||||
const isActiveGroup = isLastToolGroup && j >= messages.length;
|
||||
|
||||
// Build the items array - use toolName as id since we dedupe by toolName
|
||||
const items: ChatUI.ThinkingItem[] = uniqueTools.map((m) => ({
|
||||
id: `tool-${m.toolName}`,
|
||||
displayTitle:
|
||||
m.customDisplayTitle ||
|
||||
m.displayTitle ||
|
||||
m.toolName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' '),
|
||||
status: m.status,
|
||||
}));
|
||||
|
||||
// Only add "Thinking" spinner if this group is actively streaming (no response yet).
|
||||
// Don't add it to old completed groups when a new turn starts streaming.
|
||||
if (isActiveGroup && allToolsCompleted && options.streaming && options.loadingMessage) {
|
||||
items.push({
|
||||
id: 'thinking-item',
|
||||
displayTitle: options.loadingMessage,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
// Determine the latest status text per-group:
|
||||
// - Running tools: show tool name (dynamic)
|
||||
// - Active group + streaming: show loading message (dynamic)
|
||||
// - Completed group: "Workflow generated" if workflow-updated, else "Thinking"
|
||||
const runningTool = uniqueTools.find((m) => m.status === 'running');
|
||||
let latestStatus: string;
|
||||
|
||||
// Check if this specific group generated a workflow by looking up tool IDs
|
||||
// in the pre-computed set (built from unfiltered messages before workflow-updated was removed)
|
||||
const groupGeneratedWorkflow =
|
||||
options.toolIdsWithWorkflowUpdate?.size &&
|
||||
toolGroup.some((tool) => options.toolIdsWithWorkflowUpdate?.has(tool.id));
|
||||
|
||||
if (hasRunningTool) {
|
||||
latestStatus =
|
||||
runningTool?.customDisplayTitle ||
|
||||
runningTool?.displayTitle ||
|
||||
runningTool?.toolName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ') ||
|
||||
options.t('assistantChat.thinking.processing');
|
||||
} else if (!allToolsCompleted) {
|
||||
latestStatus = options.t('assistantChat.thinking.thinking');
|
||||
} else if (isActiveGroup && options.streaming) {
|
||||
latestStatus = options.loadingMessage ?? options.t('assistantChat.thinking.processing');
|
||||
} else if (groupGeneratedWorkflow) {
|
||||
latestStatus =
|
||||
options.thinkingCompletionMessage ?? options.t('assistantChat.thinking.workflowGenerated');
|
||||
} else {
|
||||
latestStatus = options.t('assistantChat.thinking.thinking');
|
||||
}
|
||||
|
||||
// Create a ThinkingGroup message with deduplicated items
|
||||
// Use a stable per-group ID so Vue correctly diffs multiple thinking blocks
|
||||
const thinkingGroup: ChatUI.ThinkingGroupMessage = {
|
||||
id: `thinking-group-${i}`,
|
||||
role: 'assistant',
|
||||
type: 'thinking-group',
|
||||
items,
|
||||
latestStatusText: latestStatus,
|
||||
};
|
||||
|
||||
result.push(thinkingGroup);
|
||||
i = j;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Pre-compute which tool IDs belong to groups that had workflow-updated messages.
|
||||
// This uses the original unfiltered messages because workflow-updated is filtered before grouping.
|
||||
const toolIdsWithWorkflowUpdate = computed(() => getToolIdsWithWorkflowUpdate(props.messages));
|
||||
|
||||
// Ensure all messages have required id and read properties, and group tool messages into thinking blocks
|
||||
const normalizedMessages = computed(() => {
|
||||
const normalized = normalizeMessages(props.messages);
|
||||
return groupToolMessagesIntoThinking(filterOutHiddenMessages(normalized), {
|
||||
streaming: props.streaming,
|
||||
loadingMessage: props.loadingMessage,
|
||||
thinkingCompletionMessage: props.thinkingCompletionMessage,
|
||||
toolIdsWithWorkflowUpdate: toolIdsWithWorkflowUpdate.value,
|
||||
t,
|
||||
});
|
||||
});
|
||||
|
||||
// Get quickReplies from the last message in the original messages (before filtering)
|
||||
const lastMessageQuickReplies = computed(() => {
|
||||
if (!props.messages?.length || props.streaming) return [];
|
||||
|
||||
const lastMessage = props.messages[props.messages.length - 1];
|
||||
return 'quickReplies' in lastMessage && lastMessage.quickReplies?.length
|
||||
? lastMessage.quickReplies
|
||||
: [];
|
||||
});
|
||||
|
||||
const textInputValue = ref<string>('');
|
||||
const promptInputRef = ref<InstanceType<typeof N8nPromptInput>>();
|
||||
const scrollAreaRef = ref<InstanceType<typeof N8nScrollArea>>();
|
||||
const suggestionsInputFocusFn = ref<(() => void) | null>(null);
|
||||
|
||||
const inputWrapperRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const sessionEnded = computed(() => {
|
||||
return isEndOfSessionEvent(props.messages?.[props.messages.length - 1]);
|
||||
});
|
||||
|
||||
const sendDisabled = computed(() => {
|
||||
return !textInputValue.value || props.streaming || sessionEnded.value || props.disabled;
|
||||
});
|
||||
|
||||
const showPlaceholder = computed(() => {
|
||||
return !props.messages?.length && !props.loadingMessage && !props.sessionId;
|
||||
});
|
||||
|
||||
const showSuggestions = computed(() => {
|
||||
return showPlaceholder.value && props.suggestions && props.suggestions.length > 0;
|
||||
});
|
||||
|
||||
// Show placeholder when streaming with loading message but no active tool group for the current turn.
|
||||
// Check the last message — if it's not a thinking-group, the current turn has no tools yet.
|
||||
const showThinkingPlaceholder = computed(() => {
|
||||
if (!props.streaming || !props.loadingMessage) return false;
|
||||
const lastMsg = normalizedMessages.value[normalizedMessages.value.length - 1];
|
||||
return !lastMsg || lastMsg.type !== 'thinking-group';
|
||||
});
|
||||
|
||||
const showBottomInput = computed(() => {
|
||||
// Hide bottom input when showing suggestions (blank state with suggestions)
|
||||
return !showSuggestions.value;
|
||||
});
|
||||
|
||||
const showFooterRating = computed(() => {
|
||||
if (props.streaming || !props.messages?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's a workflow-updated message in the original messages
|
||||
// (workflow-updated is filtered out of normalizedMessages since it's not rendered visually)
|
||||
// and check the last visible message (from normalizedMessages)
|
||||
const hasWorkflowUpdate = props.messages.some((msg) => msg.type === 'workflow-updated');
|
||||
if (!hasWorkflowUpdate || !normalizedMessages.value.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastMsg = normalizedMessages.value[normalizedMessages.value.length - 1];
|
||||
return lastMsg.role !== 'user' && lastMsg.type !== 'thinking-group';
|
||||
});
|
||||
|
||||
function isEndOfSessionEvent(event?: ChatUI.AssistantMessage) {
|
||||
return event?.type === 'event' && event?.eventName === 'end-session';
|
||||
}
|
||||
|
||||
async function onSuggestionClick(suggestion: WorkflowSuggestion) {
|
||||
// Populate the input field with the suggestion so user can edit before submitting
|
||||
textInputValue.value = suggestion.prompt;
|
||||
// Wait for the input to update its height before focusing
|
||||
await nextTick();
|
||||
// Wait one more frame to ensure DOM is fully updated
|
||||
await new Promise(requestAnimationFrame);
|
||||
if (suggestionsInputFocusFn.value) {
|
||||
suggestionsInputFocusFn.value();
|
||||
} else {
|
||||
promptInputRef.value?.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
function onQuickReply(opt: ChatUI.QuickReply) {
|
||||
emit('message', opt.text, opt.type, opt.isFeedback);
|
||||
}
|
||||
|
||||
function onSendMessage() {
|
||||
emit('message', textInputValue.value);
|
||||
textInputValue.value = '';
|
||||
}
|
||||
|
||||
function onRateMessage(feedback: RatingFeedback) {
|
||||
emit('feedback', feedback);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
scrollAreaRef.value?.scrollToBottom({ smooth: true });
|
||||
}
|
||||
|
||||
function scrollToBottomImmediate() {
|
||||
scrollAreaRef.value?.scrollToBottom({ smooth: false });
|
||||
}
|
||||
|
||||
watch(sendDisabled, () => {
|
||||
promptInputRef.value?.focusInput();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.messages,
|
||||
async (messages) => {
|
||||
// Check if the last message is user and scroll to bottom of the chat
|
||||
if (props.scrollOnNewMessage && messages.length > 0) {
|
||||
// Wait for DOM updates before scrolling
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// Setup initial scroll to bottom
|
||||
let isMounted = true;
|
||||
|
||||
// Watch for when the input becomes available and set up observers
|
||||
watch(
|
||||
showBottomInput,
|
||||
async (isShown) => {
|
||||
if (!isShown) {
|
||||
return;
|
||||
}
|
||||
// Wait for the input to be mounted in the DOM
|
||||
await nextTick();
|
||||
|
||||
if (!isMounted || !inputWrapperRef.value || !scrollAreaRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start at bottom
|
||||
scrollToBottomImmediate();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
isMounted = false;
|
||||
});
|
||||
|
||||
function getMessageStyles(message: ChatUI.AssistantMessage, messageCount: number) {
|
||||
const isToolOrThinking = message.type === 'tool' || message.type === 'thinking-group';
|
||||
const nextMsg = normalizedMessages.value[messageCount + 1];
|
||||
const nextIsToolOrThinking = nextMsg?.type === 'tool' || nextMsg?.type === 'thinking-group';
|
||||
|
||||
return {
|
||||
[$style.lastToolMessage]:
|
||||
isToolOrThinking &&
|
||||
((messageCount === normalizedMessages.value.length - 1 && !props.loadingMessage) ||
|
||||
(messageCount < normalizedMessages.value.length - 1 && !nextIsToolOrThinking)),
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageColor(message: ChatUI.AssistantMessage): string | undefined {
|
||||
if (isTaskAbortedMessage(message)) {
|
||||
return 'var(--color--text)';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusInput: () => {
|
||||
promptInputRef.value?.focusInput();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.chatTitle">
|
||||
<div :class="$style.headerText">
|
||||
<div :class="$style.assistantTitle">
|
||||
<AssistantIcon size="large" />
|
||||
<AssistantText size="large" :text="t('assistantChat.aiAssistantLabel')" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<N8nIconButton
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
:aria-label="t('askAssistantChat.close')"
|
||||
data-test-id="close-chat-button"
|
||||
@click="onClose"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.body">
|
||||
<div v-if="normalizedMessages?.length || loadingMessage" :class="$style.messages">
|
||||
<N8nScrollArea
|
||||
ref="scrollAreaRef"
|
||||
type="hover"
|
||||
:enable-vertical-scroll="true"
|
||||
:enable-horizontal-scroll="false"
|
||||
>
|
||||
<div ref="messagesRef" :class="$style.messagesContent">
|
||||
<div v-if="normalizedMessages?.length">
|
||||
<data
|
||||
v-for="(message, i) in normalizedMessages"
|
||||
:key="message.id"
|
||||
:data-test-id="
|
||||
message.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'
|
||||
"
|
||||
>
|
||||
<!-- Handle ThinkingGroup messages -->
|
||||
<ThinkingMessage
|
||||
v-if="isThinkingGroupMessage(message)"
|
||||
:items="message.items"
|
||||
:default-expanded="streaming"
|
||||
:latest-status-text="message.latestStatusText"
|
||||
:is-streaming="streaming"
|
||||
:class="getMessageStyles(message, i)"
|
||||
/>
|
||||
|
||||
<!-- Handle regular messages -->
|
||||
<MessageWrapper
|
||||
v-else
|
||||
:message="message"
|
||||
:is-first-of-role="i === 0 || message.role !== normalizedMessages[i - 1].role"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="i === normalizedMessages.length - 1"
|
||||
:class="getMessageStyles(message, i)"
|
||||
:color="getMessageColor(message)"
|
||||
:workflow-id="workflowId"
|
||||
:prune-time-hours="pruneTimeHours"
|
||||
@code-replace="() => emit('codeReplace', i)"
|
||||
@code-undo="() => emit('codeUndo', i)"
|
||||
@feedback="onRateMessage"
|
||||
@restore="(versionId: string) => emit('restore', versionId)"
|
||||
@restore-confirm="
|
||||
(versionId: string, messageId: string) =>
|
||||
emit('restoreConfirm', versionId, messageId)
|
||||
"
|
||||
@restore-cancel="emit('restoreCancel')"
|
||||
@show-version="(versionId: string) => emit('showVersion', versionId)"
|
||||
>
|
||||
<template v-if="$slots['custom-message']" #custom-message="customMessageProps">
|
||||
<slot name="custom-message" v-bind="customMessageProps" />
|
||||
</template>
|
||||
<template v-if="$slots['focused-nodes-chips']" #focused-nodes-chips="chipProps">
|
||||
<slot name="focused-nodes-chips" v-bind="chipProps" />
|
||||
</template>
|
||||
</MessageWrapper>
|
||||
|
||||
<div
|
||||
v-if="lastMessageQuickReplies.length && i === normalizedMessages.length - 1"
|
||||
:class="$style.quickReplies"
|
||||
>
|
||||
<div :class="$style.quickRepliesTitle">
|
||||
{{ t('assistantChat.quickRepliesTitle') }}
|
||||
</div>
|
||||
<div
|
||||
v-for="opt in lastMessageQuickReplies"
|
||||
:key="opt.type"
|
||||
data-test-id="quick-replies"
|
||||
>
|
||||
<N8nButton
|
||||
v-if="opt.text"
|
||||
variant="subtle"
|
||||
size="xsmall"
|
||||
@click="() => onQuickReply(opt)"
|
||||
>
|
||||
{{ opt.text }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</data>
|
||||
<slot name="messagesFooter" />
|
||||
</div>
|
||||
<!-- Placeholder thinking message when there are no tools yet called -->
|
||||
<ThinkingMessage
|
||||
v-if="showThinkingPlaceholder"
|
||||
:items="[
|
||||
{ id: 'thinking-placeholder', displayTitle: loadingMessage!, status: 'running' },
|
||||
]"
|
||||
:latest-status-text="loadingMessage!"
|
||||
:default-expanded="true"
|
||||
:is-streaming="true"
|
||||
:class="$style.lastToolMessage"
|
||||
/>
|
||||
</div>
|
||||
</N8nScrollArea>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showPlaceholder"
|
||||
:class="$style.placeholder"
|
||||
data-test-id="placeholder-message"
|
||||
>
|
||||
<div v-if="showSuggestions" :class="$style.suggestionsContainer">
|
||||
<N8nPromptInputSuggestions
|
||||
:suggestions="suggestions"
|
||||
:disabled="disabled"
|
||||
:streaming="streaming"
|
||||
@suggestion-click="onSuggestionClick"
|
||||
>
|
||||
<template #prompt-input>
|
||||
<slot
|
||||
v-if="$slots['suggestions-input']"
|
||||
name="suggestions-input"
|
||||
:model-value="textInputValue"
|
||||
:on-update-model-value="(val: string) => (textInputValue = val)"
|
||||
:placeholder="t('assistantChat.blankStateInputPlaceholder')"
|
||||
:disabled="disabled"
|
||||
:disabled-tooltip="disabledTooltip"
|
||||
:streaming="streaming"
|
||||
:credits-quota="creditsQuota"
|
||||
:credits-remaining="creditsRemaining"
|
||||
:show-ask-owner-tooltip="showAskOwnerTooltip"
|
||||
:max-length="maxCharacterLength"
|
||||
:on-submit="onSendMessage"
|
||||
:on-stop="() => emit('stop')"
|
||||
:on-upgrade-click="() => emit('upgrade-click')"
|
||||
:register-focus="(fn: () => void) => (suggestionsInputFocusFn = fn)"
|
||||
/>
|
||||
<N8nPromptInput
|
||||
v-else
|
||||
ref="promptInputRef"
|
||||
v-model="textInputValue"
|
||||
:placeholder="t('assistantChat.blankStateInputPlaceholder')"
|
||||
:disabled="disabled"
|
||||
:disabled-tooltip="disabledTooltip"
|
||||
:streaming="streaming"
|
||||
:credits-quota="creditsQuota"
|
||||
:credits-remaining="creditsRemaining"
|
||||
:show-ask-owner-tooltip="showAskOwnerTooltip"
|
||||
:max-length="maxCharacterLength"
|
||||
:min-lines="2"
|
||||
data-test-id="chat-suggestions-input"
|
||||
autofocus
|
||||
@upgrade-click="emit('upgrade-click')"
|
||||
@submit="onSendMessage"
|
||||
@stop="emit('stop')"
|
||||
>
|
||||
<template v-if="$slots['extra-actions']" #extra-actions>
|
||||
<slot name="extra-actions" />
|
||||
</template>
|
||||
</N8nPromptInput>
|
||||
</template>
|
||||
</N8nPromptInputSuggestions>
|
||||
</div>
|
||||
<div v-else-if="$slots.placeholder" :class="$style.info">
|
||||
<slot name="placeholder" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div :class="$style.greeting">Hi {{ user?.firstName }} 👋</div>
|
||||
<div :class="$style.info">
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.1') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.2') }}
|
||||
<InlineAskAssistantButton size="small" :static="true" />
|
||||
{{ t('assistantChat.placeholder.3') }}
|
||||
</p>
|
||||
<p>
|
||||
{{ t('assistantChat.placeholder.4') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="showFooterRating" :class="$style.feedbackWrapper" data-test-id="footer-rating">
|
||||
<MessageRating minimal @feedback="onRateMessage" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.inputHeader && (showBottomInput || showSuggestions)"
|
||||
:class="$style.inputHeaderWrapper"
|
||||
>
|
||||
<slot name="inputHeader" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showBottomInput"
|
||||
ref="inputWrapperRef"
|
||||
:class="{
|
||||
[$style.inputWrapper]: true,
|
||||
[$style.inputWrapperWithHeader]: $slots.inputHeader,
|
||||
[$style.disabledInput]: sessionEnded,
|
||||
}"
|
||||
data-test-id="chat-input-wrapper"
|
||||
>
|
||||
<div v-if="$slots.inputPlaceholder" :class="$style.inputPlaceholder">
|
||||
<slot name="inputPlaceholder" />
|
||||
</div>
|
||||
<N8nPromptInput
|
||||
v-else
|
||||
ref="promptInputRef"
|
||||
v-model="textInputValue"
|
||||
:placeholder="inputPlaceholder || t('assistantChat.inputPlaceholder')"
|
||||
:disabled="sessionEnded || disabled"
|
||||
:disabled-tooltip="disabledTooltip"
|
||||
:streaming="streaming"
|
||||
:credits-quota="creditsQuota"
|
||||
:credits-remaining="creditsRemaining"
|
||||
:show-ask-owner-tooltip="showAskOwnerTooltip"
|
||||
:max-length="maxCharacterLength"
|
||||
:refocus-after-send="true"
|
||||
data-test-id="chat-input"
|
||||
autofocus
|
||||
@upgrade-click="emit('upgrade-click')"
|
||||
@submit="onSendMessage"
|
||||
@stop="emit('stop')"
|
||||
>
|
||||
<template v-if="$slots['extra-actions']" #extra-actions>
|
||||
<slot name="extra-actions" />
|
||||
</template>
|
||||
</N8nPromptInput>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 65px; // same as header height in editor
|
||||
padding: 0 var(--spacing--sm) 0 var(--spacing--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
border: var(--border);
|
||||
border-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
> div:first-of-type {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: var(--color--background--light-2);
|
||||
border: var(--border);
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
position: relative;
|
||||
line-height: var(--line-height--xl);
|
||||
|
||||
pre,
|
||||
code {
|
||||
text-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: var(--spacing--sm);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestionsContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.messages {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.messagesContent {
|
||||
padding: var(--spacing--xs);
|
||||
padding-bottom: var(--spacing--3xl);
|
||||
|
||||
// Override p line-height from reset.scss (1.8) to use chat standard (1.5)
|
||||
:global(p) {
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.lastToolMessage {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.chatTitle {
|
||||
display: flex;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.headerText {
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.assistantTitle {
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.greeting {
|
||||
color: var(--color--text--shade-1);
|
||||
font-size: var(--font-size--md);
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.info {
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.quickReplies {
|
||||
margin-top: var(--spacing--sm);
|
||||
|
||||
> * {
|
||||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.quickRepliesTitle {
|
||||
font-size: var(--font-size--3xs);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.feedbackWrapper {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: start;
|
||||
padding: var(--spacing--2xs);
|
||||
z-index: 1;
|
||||
|
||||
&:has([data-feedback-expanded]) {
|
||||
right: 0;
|
||||
padding-top: 0;
|
||||
background-color: var(--color--background--light-2);
|
||||
}
|
||||
}
|
||||
|
||||
.inputHeaderWrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
border-left: var(--border);
|
||||
border-right: var(--border);
|
||||
background-color: var(--color--background--light-2);
|
||||
|
||||
> :first-child {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs) var(--spacing--xs);
|
||||
background-color: var(--color--background--light-2);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
border-left: var(--border);
|
||||
border-right: var(--border);
|
||||
}
|
||||
|
||||
.inputWrapperWithHeader {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.disabledInput {
|
||||
cursor: not-allowed;
|
||||
|
||||
* {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.inputPlaceholder {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
+1002
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
import AskAssistantChat from './AskAssistantChat.vue';
|
||||
|
||||
export default AskAssistantChat;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageRating from './MessageRating.vue';
|
||||
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
|
||||
|
||||
interface Props {
|
||||
message: ChatUI.AssistantMessage;
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
feedback: [RatingFeedback];
|
||||
}>();
|
||||
|
||||
const isUserMessage = computed(() => props.message.role === 'user');
|
||||
|
||||
function onRate(rating: RatingFeedback) {
|
||||
emit('feedback', rating);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.message">
|
||||
<slot></slot>
|
||||
<MessageRating
|
||||
v-if="message.showRating && !isUserMessage"
|
||||
:minimal="message.ratingStyle === 'minimal'"
|
||||
:show-feedback="message.showFeedback"
|
||||
@feedback="onRate"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.message {
|
||||
margin-bottom: var(--spacing--sm);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
</style>
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import BaseMessage from './BaseMessage.vue';
|
||||
import { useMarkdown } from './useMarkdown';
|
||||
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
|
||||
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
|
||||
|
||||
interface Props {
|
||||
message: ChatUI.SummaryBlock & { id: string; read: boolean; quickReplies?: ChatUI.QuickReply[] };
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
streaming?: boolean;
|
||||
isLastMessage?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
feedback: [RatingFeedback];
|
||||
}>();
|
||||
const { renderMarkdown } = useMarkdown();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
|
||||
>
|
||||
<div :class="$style.block">
|
||||
<div :class="$style.blockTitle">
|
||||
{{ message.title }}
|
||||
<BlinkingCursor v-if="streaming && isLastMessage && !message.content" />
|
||||
</div>
|
||||
<div :class="$style.blockBody">
|
||||
<span
|
||||
v-n8n-html="renderMarkdown(message.content)"
|
||||
:class="$style['rendered-content']"
|
||||
></span>
|
||||
<BlinkingCursor v-if="streaming && isLastMessage && message.title && message.content" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.block {
|
||||
font-size: var(--font-size--2xs);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
word-break: break-word;
|
||||
|
||||
li {
|
||||
margin-left: var(--spacing--xs);
|
||||
}
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
border-bottom: var(--border);
|
||||
padding: var(--spacing--2xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
}
|
||||
|
||||
.blockBody {
|
||||
padding: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.rendered-content {
|
||||
p {
|
||||
margin: 0;
|
||||
margin: var(--spacing--4xs) 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--xs);
|
||||
margin: var(--spacing--xs) 0 var(--spacing--4xs);
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--spacing--4xs) 0 var(--spacing--4xs) var(--spacing--lg);
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: var(--spacing--xs);
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: var(--spacing--4xs) 0;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: var(--border);
|
||||
padding: var(--spacing--4xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import BaseMessage from './BaseMessage.vue';
|
||||
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
|
||||
import CodeDiff from '../../CodeDiff/CodeDiff.vue';
|
||||
|
||||
interface Props {
|
||||
message: ChatUI.CodeDiffMessage & { id: string; read: boolean };
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
streaming?: boolean;
|
||||
isLastMessage?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
codeReplace: [];
|
||||
codeUndo: [];
|
||||
feedback: [RatingFeedback];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
|
||||
>
|
||||
<CodeDiff
|
||||
:title="message.description"
|
||||
:content="message.codeDiff"
|
||||
:replacing="message.replacing"
|
||||
:replaced="message.replaced"
|
||||
:error="message.error"
|
||||
:streaming="streaming && isLastMessage"
|
||||
@replace="emit('codeReplace')"
|
||||
@undo="emit('codeUndo')"
|
||||
/>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
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 };
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
|
||||
<div :class="$style.error" data-test-id="chat-message-system">
|
||||
<p :class="$style.errorText">
|
||||
<N8nIcon icon="triangle-alert" size="small" :class="$style.errorIcon" />
|
||||
{{ message.content }}
|
||||
</p>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
v-if="message.retry"
|
||||
size="xsmall"
|
||||
:class="$style.retryButton"
|
||||
data-test-id="error-retry-button"
|
||||
@click="() => message.retry?.()"
|
||||
>
|
||||
{{ t('generic.retry') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
border: 1px solid var(--color--foreground);
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--color--background--light-3);
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
margin-right: var(--spacing--5xs);
|
||||
color: var(--color--danger);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
color: var(--color--danger);
|
||||
font-weight: var(--font-weight--regular);
|
||||
line-height: var(--line-height--xs);
|
||||
word-break: break-word;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: var(--spacing--3xs);
|
||||
}
|
||||
</style>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import BaseMessage from './BaseMessage.vue';
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import InlineAskAssistantButton from '../../InlineAskAssistantButton/InlineAskAssistantButton.vue';
|
||||
|
||||
type EventName = 'end-session' | 'session-timeout' | 'session-error';
|
||||
|
||||
interface Props {
|
||||
message: {
|
||||
role: 'assistant';
|
||||
type: 'event';
|
||||
eventName: EventName;
|
||||
id: string;
|
||||
read: boolean;
|
||||
};
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const eventMessages: Record<EventName, { part1: string; part2: string }> = {
|
||||
'end-session': {
|
||||
part1: 'assistantChat.sessionEndMessage.1',
|
||||
part2: 'assistantChat.sessionEndMessage.2',
|
||||
},
|
||||
'session-timeout': {
|
||||
part1: 'assistantChat.sessionTimeoutMessage.1',
|
||||
part2: 'assistantChat.sessionTimeoutMessage.2',
|
||||
},
|
||||
'session-error': {
|
||||
part1: 'assistantChat.sessionErrorMessage.1',
|
||||
part2: 'assistantChat.sessionErrorMessage.2',
|
||||
},
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
|
||||
<div :class="$style.eventText" data-test-id="chat-message-system">
|
||||
<span>
|
||||
{{ t(eventMessages[message.eventName]?.part1 || 'assistantChat.unknownEvent') }}
|
||||
</span>
|
||||
<InlineAskAssistantButton size="small" :static="true" />
|
||||
<span>
|
||||
{{ t(eventMessages[message.eventName]?.part2 || '') }}
|
||||
</span>
|
||||
</div>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.eventText {
|
||||
margin-top: var(--spacing--lg);
|
||||
padding-top: var(--spacing--3xs);
|
||||
border-top: var(--border);
|
||||
color: var(--color--text);
|
||||
|
||||
> button,
|
||||
> span {
|
||||
margin-right: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import { render, fireEvent, waitFor } from '@testing-library/vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import MessageRating from './MessageRating.vue';
|
||||
|
||||
const stubs = ['N8nButton', 'N8nIconButton', 'N8nInput'];
|
||||
|
||||
// Mock i18n to return keys instead of translated text
|
||||
vi.mock('@n8n/design-system/composables/useI18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('MessageRating', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
|
||||
).toBeTruthy();
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('rating interactions', () => {
|
||||
it('should emit feedback when thumbs up is clicked', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const upButton = wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]');
|
||||
await fireEvent.click(upButton!);
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('feedback');
|
||||
expect(wrapper.emitted().feedback[0]).toEqual([{ rating: 'up' }]);
|
||||
});
|
||||
|
||||
it('should emit feedback when thumbs down is clicked', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('feedback');
|
||||
expect(wrapper.emitted().feedback[0]).toEqual([{ rating: 'down' }]);
|
||||
});
|
||||
|
||||
it('should hide rating buttons and show success after thumbs up when showFeedback is false', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: false },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const upButton = wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]');
|
||||
await fireEvent.click(upButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
|
||||
).toBeFalsy();
|
||||
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should hide rating buttons and show feedback form after thumbs down when showFeedback is true', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: true },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-submit-feedback-button"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show success immediately after thumbs down when showFeedback is false', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: false },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
|
||||
).toBeFalsy();
|
||||
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback form interactions', () => {
|
||||
it('should submit feedback and show success', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: true },
|
||||
global: { stubs: ['N8nButton', 'N8nIconButton'] }, // Don't stub n8n-input
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
await nextTick();
|
||||
|
||||
// Find the actual textarea element within the N8nInput component
|
||||
const textarea = wrapper.container.querySelector(
|
||||
'textarea[data-test-id="message-feedback-input"]',
|
||||
);
|
||||
await fireEvent.update(textarea!, 'This is my feedback about the response');
|
||||
await nextTick();
|
||||
|
||||
const submitButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-submit-feedback-button"]',
|
||||
);
|
||||
await fireEvent.click(submitButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted().feedback).toHaveLength(2);
|
||||
expect(wrapper.emitted().feedback[1]).toEqual([
|
||||
{ feedback: 'This is my feedback about the response' },
|
||||
]);
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
|
||||
).toBeFalsy();
|
||||
expect(wrapper.getByText('assistantChat.builder.success')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should cancel feedback and return to rating buttons', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: true },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
await nextTick();
|
||||
|
||||
const cancelButton = wrapper.container.querySelector(
|
||||
'n8n-button-stub[label="generic.cancel"]',
|
||||
);
|
||||
await fireEvent.click(cancelButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-feedback-input"]'),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-up-button"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.container.querySelector('[data-test-id="message-thumbs-down-button"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should focus feedback input after thumbs down in regular mode', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: true },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
|
||||
await waitFor(() => {
|
||||
const feedbackInput = wrapper.container.querySelector(
|
||||
'[data-test-id="message-feedback-input"]',
|
||||
);
|
||||
expect(feedbackInput).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear feedback text when cancelling', async () => {
|
||||
const wrapper = render(MessageRating, {
|
||||
props: { showFeedback: true },
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const downButton = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButton!);
|
||||
await nextTick();
|
||||
|
||||
const cancelButton = wrapper.container.querySelector(
|
||||
'n8n-button-stub[label="generic.cancel"]',
|
||||
);
|
||||
await fireEvent.click(cancelButton!);
|
||||
await nextTick();
|
||||
|
||||
const downButtonAgain = wrapper.container.querySelector(
|
||||
'[data-test-id="message-thumbs-down-button"]',
|
||||
);
|
||||
await fireEvent.click(downButtonAgain!);
|
||||
await nextTick();
|
||||
|
||||
const feedbackInputAfter = wrapper.container.querySelector(
|
||||
'[data-test-id="message-feedback-input"]',
|
||||
);
|
||||
expect(feedbackInputAfter?.getAttribute('modelvalue')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import type { RatingFeedback } from '../../../types';
|
||||
import N8nButton from '../../N8nButton';
|
||||
import N8nIconButton from '../../N8nIconButton';
|
||||
import N8nInput from '../../N8nInput';
|
||||
|
||||
interface Props {
|
||||
minimal?: boolean;
|
||||
showFeedback?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
minimal: false,
|
||||
showFeedback: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
feedback: [RatingFeedback];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const showRatingButtons = ref(true);
|
||||
const showFeedbackArea = ref(false);
|
||||
const feedbackInput = ref<HTMLInputElement | null>(null);
|
||||
const showSuccess = ref(false);
|
||||
const selectedRating = ref<'up' | 'down' | null>(null);
|
||||
const feedback = ref('');
|
||||
|
||||
function onRateButton(rating: 'up' | 'down') {
|
||||
selectedRating.value = rating;
|
||||
showRatingButtons.value = false;
|
||||
|
||||
emit('feedback', { rating });
|
||||
if (props.showFeedback && rating === 'down') {
|
||||
showFeedbackArea.value = true;
|
||||
setTimeout(() => {
|
||||
if (feedbackInput.value) {
|
||||
feedbackInput.value.focus();
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
showSuccess.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmitFeedback() {
|
||||
if (selectedRating.value) {
|
||||
emit('feedback', { feedback: feedback.value });
|
||||
showFeedbackArea.value = false;
|
||||
showSuccess.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onCancelFeedback() {
|
||||
showFeedbackArea.value = false;
|
||||
showRatingButtons.value = true;
|
||||
selectedRating.value = null;
|
||||
feedback.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.rating, { [$style.minimal]: minimal }]"
|
||||
:data-feedback-expanded="showFeedbackArea || undefined"
|
||||
>
|
||||
<div v-if="showRatingButtons" :class="$style.buttons">
|
||||
<template v-if="!minimal">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="t('assistantChat.builder.thumbsUp')"
|
||||
data-test-id="message-thumbs-up-button"
|
||||
icon="thumbs-up"
|
||||
@click="onRateButton('up')"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
data-test-id="message-thumbs-down-button"
|
||||
:label="t('assistantChat.builder.thumbsDown')"
|
||||
icon="thumbs-down"
|
||||
@click="onRateButton('down')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon="thumbs-up"
|
||||
icon-size="large"
|
||||
:class="$style.ratingButton"
|
||||
data-test-id="message-thumbs-up-button"
|
||||
@click="onRateButton('up')"
|
||||
/>
|
||||
<N8nIconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
icon="thumbs-down"
|
||||
icon-size="large"
|
||||
:class="$style.ratingButton"
|
||||
data-test-id="message-thumbs-down-button"
|
||||
@click="onRateButton('down')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showFeedbackArea" :class="$style.feedbackContainer">
|
||||
<N8nInput
|
||||
ref="feedbackInput"
|
||||
v-model="feedback"
|
||||
:class="$style.feedbackInput"
|
||||
type="textarea"
|
||||
:placeholder="t('assistantChat.builder.feedbackPlaceholder')"
|
||||
data-test-id="message-feedback-input"
|
||||
:read-only="false"
|
||||
resize="none"
|
||||
/>
|
||||
<div :class="$style.feedbackActions">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:label="t('generic.cancel')"
|
||||
@click="onCancelFeedback"
|
||||
/>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="small"
|
||||
data-test-id="message-submit-feedback-button"
|
||||
:label="t('assistantChat.builder.feedbackSubmit')"
|
||||
@click="onSubmitFeedback"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="showSuccess" :class="$style.success">
|
||||
{{ t('assistantChat.builder.success') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-top: var(--spacing--2xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.feedbackContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.feedbackInput {
|
||||
:global(textarea) {
|
||||
resize: none;
|
||||
font-family: var(--font-family);
|
||||
font-size: var(--font-size--sm);
|
||||
|
||||
&::placeholder {
|
||||
font-size: var(--font-size--sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feedbackActions {
|
||||
display: flex;
|
||||
gap: var(--spacing--2xs);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--color--text);
|
||||
font-size: var(--font-size--2xs);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Minimal style specific */
|
||||
.minimal {
|
||||
margin-top: 0;
|
||||
gap: 0;
|
||||
|
||||
.buttons {
|
||||
gap: var(--spacing--5xs);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--4xs);
|
||||
background-color: var(--color--background--light-2);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.ratingButton {
|
||||
--button--color--background--hover: var(--color--foreground--tint-1);
|
||||
--button--color--text--hover: var(--color--text);
|
||||
--button--color--text: var(--color--text);
|
||||
}
|
||||
|
||||
.feedbackContainer {
|
||||
gap: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.feedbackInput {
|
||||
:global(textarea) {
|
||||
font-size: var(--font-size--2xs);
|
||||
|
||||
&::placeholder {
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<!-- eslint-disable @typescript-eslint/naming-convention -->
|
||||
<!-- eslint-disable @typescript-eslint/no-unsafe-return Needed to fix types issue with imported components to satisfy Component type -->
|
||||
<script setup lang="ts">
|
||||
import { computed, type Component } from 'vue';
|
||||
|
||||
import { getSupportedMessageComponent } from './helpers';
|
||||
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
|
||||
|
||||
export interface Props {
|
||||
message: ChatUI.AssistantMessage;
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
streaming?: boolean;
|
||||
isLastMessage?: boolean;
|
||||
color?: string;
|
||||
workflowId?: string;
|
||||
pruneTimeHours?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
codeReplace: [];
|
||||
codeUndo: [];
|
||||
feedback: [RatingFeedback];
|
||||
restore: [versionId: string];
|
||||
restoreConfirm: [versionId: string, messageId: string];
|
||||
restoreCancel: [];
|
||||
showVersion: [versionId: string];
|
||||
}>();
|
||||
|
||||
const messageComponent = computed<Component | null>(() => {
|
||||
return getSupportedMessageComponent(props.message.type);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<component
|
||||
:is="messageComponent"
|
||||
v-if="messageComponent"
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="isLastMessage"
|
||||
:color="color"
|
||||
:workflow-id="workflowId"
|
||||
:prune-time-hours="pruneTimeHours"
|
||||
@code-replace="emit('codeReplace')"
|
||||
@code-undo="emit('codeUndo')"
|
||||
@feedback="(feedback: RatingFeedback) => emit('feedback', feedback)"
|
||||
@restore="(versionId: string) => emit('restore', versionId)"
|
||||
@restore-confirm="
|
||||
(versionId: string, messageId: string) => emit('restoreConfirm', versionId, messageId)
|
||||
"
|
||||
@restore-cancel="emit('restoreCancel')"
|
||||
@show-version="(versionId: string) => emit('showVersion', versionId)"
|
||||
>
|
||||
<template v-if="$slots['focused-nodes-chips']" #focused-nodes-chips="slotProps">
|
||||
<slot name="focused-nodes-chips" v-bind="slotProps" />
|
||||
</template>
|
||||
</component>
|
||||
<slot
|
||||
v-else-if="message.type === 'custom'"
|
||||
name="custom-message"
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
:streaming="streaming"
|
||||
:is-last-message="isLastMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import RestoreVersionConfirm from './RestoreVersionConfirm.vue';
|
||||
|
||||
// Mock i18n to return keys with interpolation for testing
|
||||
vi.mock('@n8n/design-system/composables/useI18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, string>) => {
|
||||
if (params) {
|
||||
return `${key}:${JSON.stringify(params)}`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const stubs = {
|
||||
N8nButton: {
|
||||
name: 'N8nButton',
|
||||
props: ['type', 'size'],
|
||||
template: '<button data-test-id="n8n-button-stub" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
N8nIcon: true,
|
||||
};
|
||||
|
||||
describe('RestoreVersionConfirm', () => {
|
||||
const defaultProps = {
|
||||
versionId: 'version-abc123',
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('restore-version-confirm')).toBeTruthy();
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.title');
|
||||
expect(wrapper.container.textContent).toContain(
|
||||
'aiAssistant.versionCard.restoreModal.showVersion',
|
||||
);
|
||||
expect(wrapper.container.textContent).toContain(
|
||||
'aiAssistant.versionCard.restoreModal.restore',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render description without limit when pruneTimeHours is undefined', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain(
|
||||
'aiAssistant.versionCard.restoreModal.descriptionNoLimit',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render description without limit when pruneTimeHours is -1', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: -1,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain(
|
||||
'aiAssistant.versionCard.restoreModal.descriptionNoLimit',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune time formatting', () => {
|
||||
it('should format 1 hour correctly (singular)', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 1,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Description should use the hour key
|
||||
expect(wrapper.container.textContent).toContain(
|
||||
'aiAssistant.versionCard.restoreModal.description',
|
||||
);
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.hour');
|
||||
});
|
||||
|
||||
it('should format multiple hours correctly (plural)', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 12,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.hours');
|
||||
});
|
||||
|
||||
it('should format 24 hours as hours not days', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 24,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// 24 hours should still be displayed as hours (not converted to days)
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.hours');
|
||||
});
|
||||
|
||||
it('should format 48 hours as 2 days', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 48,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.days');
|
||||
});
|
||||
|
||||
it('should format 25 hours as 1 day (floor division)', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 25,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.day');
|
||||
});
|
||||
|
||||
it('should format 72 hours as 3 days', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 72,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.versionCard.restoreModal.days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emissions', () => {
|
||||
it('should emit confirm when restore button is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const restoreButton = wrapper.getByTestId('n8n-button-stub');
|
||||
await fireEvent.click(restoreButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('confirm');
|
||||
// Verify at least one confirm event was emitted
|
||||
expect(wrapper.emitted().confirm.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should emit showVersion with versionId when show version button is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: {
|
||||
versionId: 'test-version-id',
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Find the show version button (it's a regular button, not N8nButton)
|
||||
const buttons = wrapper.container.querySelectorAll('button');
|
||||
const showVersionButton = Array.from(buttons).find((btn) =>
|
||||
btn.textContent?.includes('aiAssistant.versionCard.restoreModal.showVersion'),
|
||||
);
|
||||
|
||||
expect(showVersionButton).toBeTruthy();
|
||||
await fireEvent.click(showVersionButton!);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('showVersion');
|
||||
expect(wrapper.emitted().showVersion[0]).toEqual(['test-version-id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('structure and styling', () => {
|
||||
it('should have correct test id for automation', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('restore-version-confirm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render title, description, and action buttons', () => {
|
||||
const wrapper = render(RestoreVersionConfirm, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check for title
|
||||
const title = wrapper.container.querySelector('h3');
|
||||
expect(title).toBeTruthy();
|
||||
|
||||
// Check for description
|
||||
const description = wrapper.container.querySelector('p');
|
||||
expect(description).toBeTruthy();
|
||||
|
||||
// Check for buttons (show version + restore)
|
||||
const buttons = wrapper.container.querySelectorAll('button');
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import N8nButton from '../../N8nButton';
|
||||
import N8nIcon from '../../N8nIcon';
|
||||
|
||||
interface Props {
|
||||
versionId: string;
|
||||
pruneTimeHours?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [];
|
||||
cancel: [];
|
||||
showVersion: [versionId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* Format prune time for display using i18n keys
|
||||
* -1 or undefined = no limit (use different i18n key)
|
||||
* divisible by 24 = use day/days key
|
||||
* otherwise = use hour/hours key
|
||||
*/
|
||||
const formattedPruneTime = computed(() => {
|
||||
const hours = props.pruneTimeHours;
|
||||
if (hours === undefined || hours === -1) {
|
||||
return null;
|
||||
}
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
const key =
|
||||
days === 1
|
||||
? 'aiAssistant.versionCard.restoreModal.day'
|
||||
: 'aiAssistant.versionCard.restoreModal.days';
|
||||
return t(key, { count: String(days) });
|
||||
}
|
||||
const key =
|
||||
hours === 1
|
||||
? 'aiAssistant.versionCard.restoreModal.hour'
|
||||
: 'aiAssistant.versionCard.restoreModal.hours';
|
||||
return t(key, { count: String(hours) });
|
||||
});
|
||||
|
||||
const description = computed(() => {
|
||||
if (formattedPruneTime.value === null) {
|
||||
return t('aiAssistant.versionCard.restoreModal.descriptionNoLimit');
|
||||
}
|
||||
return t('aiAssistant.versionCard.restoreModal.description', {
|
||||
pruneTime: formattedPruneTime.value,
|
||||
});
|
||||
});
|
||||
|
||||
function handleShowVersion() {
|
||||
emit('showVersion', props.versionId);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="restore-version-confirm">
|
||||
<div :class="$style.content">
|
||||
<h3 :class="$style.title">
|
||||
{{ t('aiAssistant.versionCard.restoreModal.title') }}
|
||||
</h3>
|
||||
<p :class="$style.description">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<button :class="$style.showVersionButton" type="button" @click="handleShowVersion">
|
||||
{{ t('aiAssistant.versionCard.restoreModal.showVersion') }}
|
||||
<N8nIcon icon="arrow-up-right" size="xlarge" />
|
||||
</button>
|
||||
<N8nButton variant="solid" size="large" @click="handleConfirm">
|
||||
{{ t('aiAssistant.versionCard.restoreModal.restore') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--sm);
|
||||
padding: var(--spacing--md) var(--spacing--sm);
|
||||
border: 1px solid var(--color--foreground);
|
||||
border-radius: var(--radius--lg);
|
||||
background-color: var(--color--background--light-3);
|
||||
width: 288px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--3xs);
|
||||
color: var(--color--text);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--md);
|
||||
font-weight: var(--font-weight--bold);
|
||||
color: var(--color--text--shade-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--sm);
|
||||
color: var(--color--text);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
// @todo is there a reusable version here?
|
||||
.showVersionButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color--text--shade-1);
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--bold);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing--3xs) var(--spacing--sm);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color--primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import RestoreVersionLink from './RestoreVersionLink.vue';
|
||||
|
||||
// Mock i18n to return keys instead of translated text
|
||||
vi.mock('@n8n/design-system/composables/useI18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock vueuse composables
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
onClickOutside: vi.fn(),
|
||||
useElementBounding: vi.fn(() => ({
|
||||
bottom: { value: 100 },
|
||||
right: { value: 200 },
|
||||
width: { value: 150 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const stubs = {
|
||||
N8nIcon: true,
|
||||
RestoreVersionConfirm: {
|
||||
name: 'RestoreVersionConfirm',
|
||||
props: ['versionId', 'pruneTimeHours'],
|
||||
emits: ['confirm', 'cancel', 'showVersion'],
|
||||
template: `
|
||||
<div data-test-id="restore-version-confirm-stub">
|
||||
<button data-test-id="confirm-button" @click="$emit('confirm')">Confirm</button>
|
||||
<button data-test-id="cancel-button" @click="$emit('cancel')">Cancel</button>
|
||||
<button data-test-id="show-version-button" @click="$emit('showVersion', versionId)">Show Version</button>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
Teleport: {
|
||||
template: '<div data-test-id="teleport-stub"><slot /></div>',
|
||||
},
|
||||
};
|
||||
|
||||
describe('RestoreVersionLink', () => {
|
||||
const defaultProps = {
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date('2024-01-15T10:30:00').toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the restore button with formatted date', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Should render the restore button
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
// Should display the i18n key for restore version text
|
||||
expect(wrapper.container.textContent).toContain('aiAssistant.textMessage.restoreVersion');
|
||||
});
|
||||
|
||||
it('should render decorative lines on both sides of the button', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Check for the restore container structure with lines
|
||||
const container = wrapper.container.querySelector('[class*="restoreContainer"]');
|
||||
expect(container).toBeTruthy();
|
||||
|
||||
// Should have line elements (class names are hashed in CSS modules)
|
||||
const lines = wrapper.container.querySelectorAll('[class*="restoreLine"]');
|
||||
expect(lines.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not show confirm dialog initially', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should disable button when streaming is true', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
streaming: true,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.className).toContain('disabled');
|
||||
});
|
||||
|
||||
it('should enable button when streaming is false', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
streaming: false,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button?.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date formatting', () => {
|
||||
it('should format date correctly with 24-hour time', () => {
|
||||
// Create a date and render
|
||||
const testDate = new Date('2024-03-20T14:05:00');
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: testDate.toISOString(),
|
||||
},
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// The button text should contain a formatted date
|
||||
// Note: Exact format depends on locale, but should include month, day, hour, minute
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle empty createdAt gracefully', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: '',
|
||||
},
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Should still render without errors
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm dialog interactions', () => {
|
||||
it('should show confirm dialog when button is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply active class when confirm dialog is shown', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
expect(button?.className).toContain('active');
|
||||
});
|
||||
|
||||
it('should have disabled button when streaming (click prevention relies on HTML disabled attribute)', () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
streaming: true,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
const button = wrapper.container.querySelector('button');
|
||||
// Verify the button is properly disabled and has the disabled class
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(button?.className).toContain('disabled');
|
||||
// Note: HTML disabled buttons prevent click events natively in browsers
|
||||
// fireEvent.click in testing-library may not fully respect disabled state
|
||||
});
|
||||
|
||||
it('should emit restoreConfirm with versionId when confirm is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Open the dialog
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = wrapper.getByTestId('confirm-button');
|
||||
await fireEvent.click(confirmButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('restoreConfirm');
|
||||
expect(wrapper.emitted().restoreConfirm[0]).toEqual(['version-123']);
|
||||
});
|
||||
|
||||
it('should close confirm dialog and emit restoreCancel when cancel is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Open the dialog
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = wrapper.getByTestId('cancel-button');
|
||||
await fireEvent.click(cancelButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('restoreCancel');
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should emit showVersion with versionId when show version is clicked', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Open the dialog
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
// Click show version
|
||||
const showVersionButton = wrapper.getByTestId('show-version-button');
|
||||
await fireEvent.click(showVersionButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('showVersion');
|
||||
expect(wrapper.emitted().showVersion[0]).toEqual(['version-123']);
|
||||
});
|
||||
|
||||
it('should close confirm dialog after confirm action', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: defaultProps,
|
||||
global: { stubs },
|
||||
});
|
||||
|
||||
// Open the dialog
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeTruthy();
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = wrapper.getByTestId('confirm-button');
|
||||
await fireEvent.click(confirmButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneTimeHours prop', () => {
|
||||
it('should pass pruneTimeHours to RestoreVersionConfirm', async () => {
|
||||
const wrapper = render(RestoreVersionLink, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
pruneTimeHours: 48,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
...stubs,
|
||||
RestoreVersionConfirm: {
|
||||
name: 'RestoreVersionConfirm',
|
||||
props: ['versionId', 'pruneTimeHours'],
|
||||
template:
|
||||
'<div data-test-id="restore-version-confirm-stub" :data-prune-hours="pruneTimeHours"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Open the dialog
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
await nextTick();
|
||||
|
||||
const confirmDialog = wrapper.getByTestId('restore-version-confirm-stub');
|
||||
expect(confirmDialog.getAttribute('data-prune-hours')).toBe('48');
|
||||
});
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside, useElementBounding } from '@vueuse/core';
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
|
||||
import RestoreVersionConfirm from './RestoreVersionConfirm.vue';
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import IconTextButton from '../../IconTextButton';
|
||||
|
||||
interface Props {
|
||||
revertVersion: { id: string; createdAt: string };
|
||||
streaming?: boolean;
|
||||
pruneTimeHours?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
restoreConfirm: [versionId: string];
|
||||
restoreCancel: [];
|
||||
showVersion: [versionId: string];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Restore version confirm dialog
|
||||
const showRestoreConfirm = ref(false);
|
||||
const restoreButtonRef = ref<{ buttonRef: Ref<HTMLButtonElement | null> } | null>(null);
|
||||
const restoreConfirmRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Close confirm dialog when clicking outside
|
||||
onClickOutside(restoreConfirmRef, () => {
|
||||
if (showRestoreConfirm.value) {
|
||||
showRestoreConfirm.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.revertVersion?.createdAt) return '';
|
||||
const date = new Date(props.revertVersion.createdAt);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate modal position based on button location
|
||||
const buttonElement = computed(() => restoreButtonRef.value?.buttonRef ?? null);
|
||||
const restoreButtonBounding = useElementBounding(buttonElement);
|
||||
|
||||
const modalStyle = computed(() => {
|
||||
if (!showRestoreConfirm.value) return {};
|
||||
|
||||
// Position modal below the button, aligned to the right
|
||||
return {
|
||||
position: 'fixed' as const,
|
||||
top: `${restoreButtonBounding.bottom.value + 8}px`,
|
||||
right: `${window.innerWidth - restoreButtonBounding.right.value - restoreButtonBounding.width.value / 4}px`,
|
||||
zIndex: 9999,
|
||||
};
|
||||
});
|
||||
|
||||
function handleRestoreClick() {
|
||||
showRestoreConfirm.value = true;
|
||||
}
|
||||
|
||||
function handleRestoreConfirm() {
|
||||
emit('restoreConfirm', props.revertVersion.id);
|
||||
showRestoreConfirm.value = false;
|
||||
}
|
||||
|
||||
function handleRestoreCancel() {
|
||||
emit('restoreCancel');
|
||||
showRestoreConfirm.value = false;
|
||||
}
|
||||
|
||||
function handleShowVersion(versionId: string) {
|
||||
emit('showVersion', versionId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.restoreWrapper">
|
||||
<div :class="$style.restoreContainer">
|
||||
<div :class="$style.restoreLine"></div>
|
||||
<IconTextButton
|
||||
ref="restoreButtonRef"
|
||||
icon="undo-2"
|
||||
:disabled="streaming"
|
||||
:active="showRestoreConfirm"
|
||||
@click="handleRestoreClick"
|
||||
>
|
||||
{{ t('aiAssistant.textMessage.restoreVersion') }} · {{ formattedDate }}
|
||||
</IconTextButton>
|
||||
<div :class="$style.restoreLine"></div>
|
||||
</div>
|
||||
<!-- Teleport modal to body to escape the messages container stacking context -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showRestoreConfirm" ref="restoreConfirmRef" :style="modalStyle">
|
||||
<RestoreVersionConfirm
|
||||
:version-id="revertVersion.id"
|
||||
:prune-time-hours="pruneTimeHours"
|
||||
@confirm="handleRestoreConfirm"
|
||||
@cancel="handleRestoreCancel"
|
||||
@show-version="handleShowVersion"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.restoreWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.restoreContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.restoreLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--color--text--tint-1);
|
||||
}
|
||||
</style>
|
||||
+488
@@ -0,0 +1,488 @@
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import TextMessage from './TextMessage.vue';
|
||||
import type { ChatUI } from '../../../types/assistant';
|
||||
|
||||
// Mock i18n to return keys instead of translated text
|
||||
vi.mock('@n8n/design-system/composables/useI18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useMarkdown
|
||||
vi.mock('./useMarkdown', () => ({
|
||||
useMarkdown: () => ({
|
||||
renderMarkdown: (content: string) => `<p>${content}</p>`,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock vueuse composables for RestoreVersionLink
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
onClickOutside: vi.fn(),
|
||||
useElementBounding: vi.fn(() => ({
|
||||
bottom: { value: 100 },
|
||||
right: { value: 200 },
|
||||
width: { value: 150 },
|
||||
})),
|
||||
}));
|
||||
|
||||
const stubs = {
|
||||
BaseMessage: {
|
||||
template: '<div data-test-id="base-message-stub"><slot /></div>',
|
||||
props: ['message', 'isFirstOfRole', 'user'],
|
||||
},
|
||||
BlinkingCursor: true,
|
||||
N8nButton: true,
|
||||
N8nIcon: true,
|
||||
RestoreVersionConfirm: {
|
||||
name: 'RestoreVersionConfirm',
|
||||
props: ['versionId', 'pruneTimeHours'],
|
||||
emits: ['confirm', 'cancel', 'showVersion'],
|
||||
template: `
|
||||
<div data-test-id="restore-version-confirm-stub">
|
||||
<button data-test-id="confirm-restore" @click="$emit('confirm')">Confirm</button>
|
||||
<button data-test-id="cancel-restore" @click="$emit('cancel')">Cancel</button>
|
||||
<button data-test-id="show-version" @click="$emit('showVersion', versionId)">Show</button>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
Teleport: {
|
||||
template: '<div data-test-id="teleport-stub"><slot /></div>',
|
||||
},
|
||||
};
|
||||
|
||||
const directives = {
|
||||
n8nHtml: (el: HTMLElement, binding: { value: string }) => {
|
||||
el.innerHTML = binding.value;
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('TextMessage', () => {
|
||||
const createUserMessage = (
|
||||
overrides: Partial<ChatUI.TextMessage> = {},
|
||||
): ChatUI.TextMessage & { id: string; read: boolean } => ({
|
||||
id: 'msg-1',
|
||||
type: 'text',
|
||||
role: 'user',
|
||||
content: 'Test user message content',
|
||||
read: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createAssistantMessage = (
|
||||
overrides: Partial<ChatUI.TextMessage> = {},
|
||||
): ChatUI.TextMessage & { id: string; read: boolean } => ({
|
||||
id: 'msg-1',
|
||||
type: 'text',
|
||||
role: 'assistant',
|
||||
content: 'Test assistant message content',
|
||||
read: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('basic rendering', () => {
|
||||
it('should render user message correctly', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('base-message-stub')).toBeTruthy();
|
||||
expect(wrapper.container.textContent).toContain('Test user message content');
|
||||
});
|
||||
|
||||
it('should render assistant message correctly', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.container.textContent).toContain('Test assistant message content');
|
||||
});
|
||||
|
||||
it('should apply userMessage class for user role', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const textMessage = wrapper.container.querySelector('[class*="userMessage"]');
|
||||
expect(textMessage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RestoreVersionLink integration', () => {
|
||||
it('should render RestoreVersionLink when user message has revertVersion', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// RestoreVersionLink should be rendered (check for its structure)
|
||||
const restoreWrapper = wrapper.container.querySelector('[class*="restoreWrapper"]');
|
||||
expect(restoreWrapper).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render RestoreVersionLink when user message has no revertVersion', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const restoreWrapper = wrapper.container.querySelector('[class*="restoreWrapper"]');
|
||||
expect(restoreWrapper).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not render RestoreVersionLink for assistant messages even with revertVersion', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage({
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const restoreWrapper = wrapper.container.querySelector('[class*="restoreWrapper"]');
|
||||
expect(restoreWrapper).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should pass streaming prop to RestoreVersionLink', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
streaming: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// When streaming, the restore button should be disabled
|
||||
const button = wrapper.container.querySelector('[class*="restoreWrapper"] button[disabled]');
|
||||
expect(button).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should pass pruneTimeHours prop to RestoreVersionLink', async () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
pruneTimeHours: 72,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// Click to open the confirm dialog
|
||||
const restoreButton = wrapper.container.querySelector('[class*="restoreWrapper"] button');
|
||||
expect(restoreButton).toBeTruthy();
|
||||
await fireEvent.click(restoreButton!);
|
||||
await nextTick();
|
||||
|
||||
// Verify the confirm dialog is shown
|
||||
expect(wrapper.queryByTestId('restore-version-confirm-stub')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit restoreConfirm with versionId and messageId when confirm is triggered', async () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
id: 'msg-abc',
|
||||
revertVersion: {
|
||||
id: 'version-xyz',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// Open the confirm dialog
|
||||
const restoreButton = wrapper.container.querySelector('[class*="restoreWrapper"] button');
|
||||
await fireEvent.click(restoreButton!);
|
||||
await nextTick();
|
||||
|
||||
// Click confirm
|
||||
const confirmButton = wrapper.getByTestId('confirm-restore');
|
||||
await fireEvent.click(confirmButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('restoreConfirm');
|
||||
expect(wrapper.emitted().restoreConfirm[0]).toEqual(['version-xyz', 'msg-abc']);
|
||||
});
|
||||
|
||||
it('should emit restoreCancel when cancel is triggered', async () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
revertVersion: {
|
||||
id: 'version-123',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// Open the confirm dialog
|
||||
const restoreButton = wrapper.container.querySelector('[class*="restoreWrapper"] button');
|
||||
await fireEvent.click(restoreButton!);
|
||||
await nextTick();
|
||||
|
||||
// Click cancel
|
||||
const cancelButton = wrapper.getByTestId('cancel-restore');
|
||||
await fireEvent.click(cancelButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('restoreCancel');
|
||||
});
|
||||
|
||||
it('should emit showVersion with versionId when show version is triggered', async () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
revertVersion: {
|
||||
id: 'version-show-test',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// Open the confirm dialog
|
||||
const restoreButton = wrapper.container.querySelector('[class*="restoreWrapper"] button');
|
||||
await fireEvent.click(restoreButton!);
|
||||
await nextTick();
|
||||
|
||||
// Click show version
|
||||
const showButton = wrapper.getByTestId('show-version');
|
||||
await fireEvent.click(showButton);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.emitted()).toHaveProperty('showVersion');
|
||||
expect(wrapper.emitted().showVersion[0]).toEqual(['version-show-test']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('user message expand/collapse', () => {
|
||||
it('should not show expand button for short messages', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({ content: 'Short message' }),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const showMoreButton = wrapper.container.querySelector('[class*="showMoreButton"]');
|
||||
expect(showMoreButton).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('code snippet rendering', () => {
|
||||
it('should render code snippet when provided', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage({
|
||||
codeSnippet: 'const x = 1;',
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('assistant-code-snippet')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render code snippet when not provided', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.queryByTestId('assistant-code-snippet')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming indicator', () => {
|
||||
it('should show blinking cursor for streaming assistant messages that are last', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
streaming: true,
|
||||
isLastMessage: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// BlinkingCursor should be rendered (stubbed as true, so just check it's there)
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('blinking-cursor');
|
||||
});
|
||||
|
||||
it('should not show blinking cursor for user messages', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
isFirstOfRole: true,
|
||||
streaming: true,
|
||||
isLastMessage: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
// BlinkingCursor should not be rendered for user messages
|
||||
const html = wrapper.html();
|
||||
expect(html).not.toContain('blinking-cursor');
|
||||
});
|
||||
|
||||
it('should not show blinking cursor when not streaming', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
streaming: false,
|
||||
isLastMessage: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const html = wrapper.html();
|
||||
expect(html).not.toContain('blinking-cursor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('focused nodes chips', () => {
|
||||
it('should render focused nodes fallback when focusedNodeNames are provided and slot is not filled', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
focusedNodeNames: ['HTTP Request', 'Set Node'],
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('message-focused-nodes')).toBeTruthy();
|
||||
expect(wrapper.container.textContent).toContain("Focusing on 'HTTP Request', 'Set Node'");
|
||||
});
|
||||
|
||||
it('should not render focused nodes wrapper when focusedNodeNames is empty', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({ focusedNodeNames: [] }),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.queryByTestId('message-focused-nodes')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not render focused nodes wrapper when focusedNodeNames is undefined', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
expect(wrapper.queryByTestId('message-focused-nodes')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should render focused nodes inside the user message container', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createUserMessage({
|
||||
focusedNodeNames: ['Node 1'],
|
||||
}),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const userContainer = wrapper.container.querySelector('[class*="userMessageContainer"]');
|
||||
const focusedNodes = wrapper.getByTestId('message-focused-nodes');
|
||||
expect(userContainer?.contains(focusedNodes)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom color', () => {
|
||||
it('should apply custom color to assistant text', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
color: 'red',
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const assistantText = wrapper.container.querySelector('[class*="assistantText"]');
|
||||
expect(assistantText?.getAttribute('style')).toContain('color: red');
|
||||
});
|
||||
|
||||
it('should not apply style when color is not provided', () => {
|
||||
const wrapper = render(TextMessage, {
|
||||
props: {
|
||||
message: createAssistantMessage(),
|
||||
isFirstOfRole: true,
|
||||
},
|
||||
global: { stubs, directives },
|
||||
});
|
||||
|
||||
const assistantText = wrapper.container.querySelector('[class*="assistantText"]');
|
||||
expect(assistantText?.getAttribute('style')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, nextTick, watch } from 'vue';
|
||||
|
||||
import BaseMessage from './BaseMessage.vue';
|
||||
import RestoreVersionLink from './RestoreVersionLink.vue';
|
||||
import { useMarkdown } from './useMarkdown';
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import type { ChatUI, RatingFeedback } from '../../../types/assistant';
|
||||
import BlinkingCursor from '../../BlinkingCursor/BlinkingCursor.vue';
|
||||
import N8nButton from '../../N8nButton';
|
||||
|
||||
interface Props {
|
||||
message: ChatUI.TextMessage & { quickReplies?: ChatUI.QuickReply[] };
|
||||
isFirstOfRole: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
streaming?: boolean;
|
||||
isLastMessage?: boolean;
|
||||
color?: string;
|
||||
pruneTimeHours?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
feedback: [RatingFeedback];
|
||||
restoreConfirm: [versionId: string, messageId: string];
|
||||
restoreCancel: [];
|
||||
showVersion: [versionId: string];
|
||||
}>();
|
||||
const { renderMarkdown } = useMarkdown();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isClipboardSupported = computed(() => {
|
||||
return navigator.clipboard?.writeText;
|
||||
});
|
||||
|
||||
// User message expand/collapse functionality
|
||||
const isExpanded = ref(false);
|
||||
const userContentRef = ref<HTMLElement | null>(null);
|
||||
const isOverflowing = ref(false);
|
||||
// Should match --assistant--text-message--collapsed--max-height in _tokens.scss
|
||||
const MAX_HEIGHT = 200;
|
||||
|
||||
function checkOverflow() {
|
||||
if (userContentRef.value) {
|
||||
isOverflowing.value = userContentRef.value.scrollHeight > MAX_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void nextTick(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.message.content,
|
||||
() => {
|
||||
void nextTick(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const fallbackFocusedNodesLabel = computed(() => {
|
||||
const names = props.message.focusedNodeNames;
|
||||
if (!names?.length) return '';
|
||||
return `Focusing on ${names.map((n) => `'${n}'`).join(', ')}`;
|
||||
});
|
||||
|
||||
async function onCopyButtonClick(content: string, e: MouseEvent) {
|
||||
const button = e.target as HTMLButtonElement;
|
||||
await navigator.clipboard.writeText(content);
|
||||
button.innerText = t('assistantChat.copied');
|
||||
setTimeout(() => {
|
||||
button.innerText = t('assistantChat.copy');
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage
|
||||
:message="message"
|
||||
:is-first-of-role="isFirstOfRole"
|
||||
:user="user"
|
||||
@feedback="(feedback) => emit('feedback', feedback)"
|
||||
>
|
||||
<div :class="[$style.textMessage, { [$style.userMessage]: message.role === 'user' }]">
|
||||
<!-- Restore version link for user messages with revertVersion - positioned before the message -->
|
||||
<RestoreVersionLink
|
||||
v-if="message.role === 'user' && message.revertVersion && message.id"
|
||||
:revert-version="message.revertVersion"
|
||||
:streaming="streaming"
|
||||
:prune-time-hours="pruneTimeHours"
|
||||
@restore-confirm="(versionId) => emit('restoreConfirm', versionId, message.id!)"
|
||||
@restore-cancel="emit('restoreCancel')"
|
||||
@show-version="(versionId) => emit('showVersion', versionId)"
|
||||
/>
|
||||
<!-- User message with container -->
|
||||
<div v-if="message.role === 'user'" :class="$style.userMessageContainer">
|
||||
<div
|
||||
ref="userContentRef"
|
||||
:class="[$style.userContent, { [$style.collapsed]: !isExpanded && isOverflowing }]"
|
||||
>
|
||||
<span v-n8n-html="renderMarkdown(message.content)" :class="$style.renderedContent"></span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isOverflowing"
|
||||
:class="$style.showMoreButton"
|
||||
type="button"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
{{ isExpanded ? t('notice.showLess') : t('notice.showMore') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="message.focusedNodeNames?.length"
|
||||
:class="$style.focusedNodesSlotWrapper"
|
||||
data-test-id="message-focused-nodes"
|
||||
>
|
||||
<slot name="focused-nodes-chips" :message="message">
|
||||
<span :class="$style.focusedNodesFallback">
|
||||
{{ fallbackFocusedNodesLabel }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Assistant message -->
|
||||
<div
|
||||
v-else
|
||||
v-n8n-html="renderMarkdown(message.content)"
|
||||
:class="[$style.assistantText, $style.renderedContent]"
|
||||
:style="color ? { color } : undefined"
|
||||
></div>
|
||||
<div
|
||||
v-if="message?.codeSnippet"
|
||||
:class="$style.codeSnippet"
|
||||
data-test-id="assistant-code-snippet"
|
||||
>
|
||||
<header v-if="isClipboardSupported">
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
data-test-id="assistant-copy-snippet-button"
|
||||
@click="onCopyButtonClick(message.codeSnippet, $event)"
|
||||
>
|
||||
{{ t('assistantChat.copy') }}
|
||||
</N8nButton>
|
||||
</header>
|
||||
<div
|
||||
v-n8n-html="renderMarkdown(message.codeSnippet).trim()"
|
||||
data-test-id="assistant-code-snippet-content"
|
||||
:class="[$style.snippetContent, $style.renderedContent]"
|
||||
></div>
|
||||
</div>
|
||||
<BlinkingCursor v-if="streaming && isLastMessage && message.role === 'assistant'" />
|
||||
</div>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.textMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
// User messages align right
|
||||
.userMessage {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
// User message container styles per Figma
|
||||
.userMessageContainer {
|
||||
background-color: var(--assistant--color--background--user-bubble);
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--2xs) var(--spacing--xs);
|
||||
color: var(--assistant--color--text--user-bubble);
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.userContent {
|
||||
&.collapsed {
|
||||
max-height: var(--assistant--text-message--collapsed--max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.showMoreButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin-top: var(--spacing--2xs);
|
||||
color: var(--assistant--color--text--subtle);
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.codeSnippet {
|
||||
position: relative;
|
||||
border: var(--border);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-family--monospace);
|
||||
font-size: var(--font-size--3xs);
|
||||
max-height: 218px; // 12 lines
|
||||
overflow: auto;
|
||||
margin: var(--spacing--4xs) 0;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--spacing--4xs);
|
||||
border-bottom: var(--border);
|
||||
|
||||
button:active,
|
||||
button:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.snippetContent {
|
||||
padding: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space-collapse: collapse;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: transparent;
|
||||
font-size: var(--font-size--3xs);
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant message - simple text
|
||||
.assistantText {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
color: var(--assistant--color--text);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
max-width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.renderedContent {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Hide horizontal rules - they don't look good in chat messages
|
||||
hr {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--xs);
|
||||
margin: var(--spacing--xs) 0 var(--spacing--4xs);
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--2xs);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: var(--spacing--4xs) 0 var(--spacing--4xs) var(--spacing--lg);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing--5xs);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: var(--spacing--xs);
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.table-wrapper) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: var(--spacing--4xs) 0;
|
||||
|
||||
th {
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: var(--border);
|
||||
padding: var(--spacing--4xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.focusedNodesSlotWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing--4xs);
|
||||
margin-top: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.focusedNodesFallback {
|
||||
font-size: var(--font-size--3xs);
|
||||
color: var(--color--text--tint-2);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import ThinkingMessage from './ThinkingMessage.vue';
|
||||
import type { ChatUI } from '../../../types/assistant';
|
||||
|
||||
describe('ThinkingMessage', () => {
|
||||
const mockItems: ChatUI.ThinkingItem[] = [
|
||||
{
|
||||
id: 'tool-1',
|
||||
displayTitle: 'Search Results',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'tool-2',
|
||||
displayTitle: 'Processing Data',
|
||||
status: 'running',
|
||||
},
|
||||
];
|
||||
|
||||
it('renders with items collapsed when defaultExpanded is false', () => {
|
||||
const { container, queryByText } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Processing...');
|
||||
|
||||
expect(queryByText('Search Results')).toBeNull();
|
||||
expect(queryByText('Processing Data')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with items expanded when defaultExpanded is true', () => {
|
||||
const { container, getByText } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Processing...');
|
||||
|
||||
expect(getByText('Search Results')).toBeInTheDocument();
|
||||
expect(getByText('Processing Data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to collapsed when defaultExpanded prop is not provided', () => {
|
||||
const { queryByText } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
latestStatusText: 'Processing...',
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText('Search Results')).toBeNull();
|
||||
expect(queryByText('Processing Data')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles expansion when header is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, queryByText, getByText } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText('Search Results')).toBeNull();
|
||||
|
||||
const headerButton = getByRole('button');
|
||||
await user.click(headerButton);
|
||||
|
||||
expect(getByText('Search Results')).toBeInTheDocument();
|
||||
expect(getByText('Processing Data')).toBeInTheDocument();
|
||||
|
||||
await user.click(headerButton);
|
||||
|
||||
expect(queryByText('Search Results')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows shimmer animation when there is a running item', () => {
|
||||
const { container } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: mockItems,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
});
|
||||
|
||||
const statusText = container.querySelector('button > span');
|
||||
expect(statusText?.className).toContain('shimmer');
|
||||
});
|
||||
|
||||
it('does not show shimmer animation when all items are completed', () => {
|
||||
const completedItems: ChatUI.ThinkingItem[] = [
|
||||
{
|
||||
id: 'tool-1',
|
||||
displayTitle: 'Search Results',
|
||||
status: 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items: completedItems,
|
||||
latestStatusText: 'Completed',
|
||||
defaultExpanded: false,
|
||||
},
|
||||
});
|
||||
|
||||
const statusText = container.querySelector('button > span');
|
||||
expect(statusText?.className).not.toContain('shimmer');
|
||||
});
|
||||
|
||||
it('auto-collapses when isStreaming transitions from true to false', async () => {
|
||||
const items: ChatUI.ThinkingItem[] = [
|
||||
{
|
||||
id: 'tool-1',
|
||||
displayTitle: 'Processing',
|
||||
status: 'running',
|
||||
},
|
||||
];
|
||||
|
||||
const { rerender, getByText, queryByText } = render(ThinkingMessage, {
|
||||
props: {
|
||||
items,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: true,
|
||||
isStreaming: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Initially expanded and showing the item
|
||||
expect(getByText('Processing')).toBeInTheDocument();
|
||||
|
||||
// isStreaming transitions to false
|
||||
await rerender({
|
||||
items,
|
||||
latestStatusText: 'Processing...',
|
||||
defaultExpanded: true,
|
||||
isStreaming: false,
|
||||
});
|
||||
|
||||
// Should auto-collapse - item no longer visible
|
||||
expect(queryByText('Processing')).toBeNull();
|
||||
});
|
||||
});
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
import type { ChatUI } from '../../../types/assistant';
|
||||
import N8nIcon from '../../N8nIcon';
|
||||
|
||||
interface Props {
|
||||
items: ChatUI.ThinkingItem[];
|
||||
latestStatusText: string;
|
||||
isStreaming?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isStreaming: false,
|
||||
defaultExpanded: false,
|
||||
});
|
||||
|
||||
const isExpanded = ref(props.defaultExpanded);
|
||||
|
||||
const hasRunningItem = computed(() => props.items.some((item) => item.status === 'running'));
|
||||
|
||||
watch(
|
||||
() => props.isStreaming,
|
||||
(newValue, oldValue) => {
|
||||
if (oldValue && !newValue) {
|
||||
isExpanded.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const shouldShowShimmer = computed(() => hasRunningItem.value);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
function getIconForStatus(status: ChatUI.ThinkingItem['status']) {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: 'circle-check' as const, spin: false };
|
||||
case 'running':
|
||||
return { icon: 'spinner' as const, spin: true };
|
||||
case 'error':
|
||||
return { icon: 'triangle-alert' as const, spin: false };
|
||||
default:
|
||||
return { icon: 'circle' as const, spin: false };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.thinkingContainer">
|
||||
<!-- Collapsed header with shimmer -->
|
||||
<button :class="$style.header" type="button" @click="toggleExpanded">
|
||||
<span :class="[$style.statusText, { [$style.shimmer]: shouldShowShimmer }]">
|
||||
{{ latestStatusText }}
|
||||
</span>
|
||||
<N8nIcon :icon="isExpanded ? 'chevron-down' : 'chevron-right'" :class="$style.chevron" />
|
||||
</button>
|
||||
|
||||
<!-- Expanded item list -->
|
||||
<Transition name="expand">
|
||||
<div v-if="isExpanded" :class="$style.itemList">
|
||||
<div v-for="item in items" :key="item.id" :class="$style.item">
|
||||
<N8nIcon
|
||||
:icon="getIconForStatus(item.status).icon"
|
||||
:spin="getIconForStatus(item.status).spin"
|
||||
:class="$style.itemIcon"
|
||||
/>
|
||||
<span :class="$style.itemText">
|
||||
{{ item.displayTitle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../../../css/mixins/animations';
|
||||
|
||||
.thinkingContainer {
|
||||
margin: var(--spacing--4xs) 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--4xs);
|
||||
padding: var(--spacing--4xs) 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--assistant--color--text--subtle);
|
||||
flex-shrink: 0;
|
||||
width: var(--font-size--lg);
|
||||
height: var(--font-size--lg);
|
||||
padding-top: 1px;
|
||||
|
||||
svg {
|
||||
width: var(--font-size--lg);
|
||||
height: var(--font-size--lg);
|
||||
}
|
||||
}
|
||||
|
||||
.statusText {
|
||||
color: var(--assistant--color--text--subtle);
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--medium);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
@include animations.shimmer;
|
||||
}
|
||||
|
||||
.itemList {
|
||||
padding-left: var(--spacing--4xs);
|
||||
padding-bottom: var(--spacing--4xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
flex-shrink: 0;
|
||||
width: var(--font-size--md);
|
||||
height: var(--font-size--md);
|
||||
color: var(--assistant--icon-color--subtle);
|
||||
|
||||
svg {
|
||||
width: var(--font-size--md);
|
||||
height: var(--font-size--md);
|
||||
}
|
||||
}
|
||||
|
||||
.itemText {
|
||||
color: var(--assistant--color--text--subtle);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// Expand transition
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.expand-enter-to,
|
||||
.expand-leave-from {
|
||||
opacity: 1;
|
||||
max-height: 500px;
|
||||
}
|
||||
</style>
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import type { Props } from './ToolMessage.vue';
|
||||
import ToolMessage from './ToolMessage.vue';
|
||||
import type { ChatUI } from '../../../types/assistant';
|
||||
|
||||
// Mock i18n to return keys instead of translated text
|
||||
vi.mock('@n8n/design-system/composables/useI18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Common mount options to reduce duplication
|
||||
const createMountOptions = (props: Props) => ({
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
BaseMessage: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
N8nIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to mount ToolMessage with common options
|
||||
const mountToolMessage = (props: Props) => mount(ToolMessage, createMountOptions(props));
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe('ToolMessage', () => {
|
||||
const baseMessage: ChatUI.ToolMessage & { id: string; read: boolean } = {
|
||||
id: 'test-tool-message',
|
||||
role: 'assistant',
|
||||
type: 'tool',
|
||||
toolName: 'search_files',
|
||||
status: 'running',
|
||||
updates: [],
|
||||
read: false,
|
||||
};
|
||||
|
||||
const user = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
};
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render correctly with basic props', () => {
|
||||
const wrapper = mountToolMessage({
|
||||
message: baseMessage,
|
||||
isFirstOfRole: true,
|
||||
user,
|
||||
});
|
||||
|
||||
expect(wrapper.find('.toolMessage').exists()).toBe(true);
|
||||
expect(wrapper.find('.header').exists()).toBe(true);
|
||||
expect(wrapper.find('.titleRow').exists()).toBe(true);
|
||||
expect(wrapper.find('.status').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should render with custom display title', () => {
|
||||
const messageWithCustomTitle = {
|
||||
...baseMessage,
|
||||
customDisplayTitle: 'Custom Tool Name',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: messageWithCustomTitle,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Custom Tool Name');
|
||||
});
|
||||
|
||||
it('should render with display title', () => {
|
||||
const messageWithDisplayTitle = {
|
||||
...baseMessage,
|
||||
displayTitle: 'Display Tool Name',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: messageWithDisplayTitle,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Display Tool Name');
|
||||
});
|
||||
|
||||
it('should render tool name in title case when no custom titles', () => {
|
||||
const messageWithSnakeCase = {
|
||||
...baseMessage,
|
||||
toolName: 'search_file_contents',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: messageWithSnakeCase,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Search File Contents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status handling', () => {
|
||||
it('should render running status with spinner icon', () => {
|
||||
const runningMessage = {
|
||||
...baseMessage,
|
||||
status: 'running' as const,
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: runningMessage,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('icon="spinner"');
|
||||
expect(wrapper.html()).toContain('spin');
|
||||
});
|
||||
|
||||
it('should render completed status with check icon', () => {
|
||||
const completedMessage = {
|
||||
...baseMessage,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: completedMessage,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('icon="circle-check"');
|
||||
});
|
||||
|
||||
it('should render error status with error icon', () => {
|
||||
const errorMessage = {
|
||||
...baseMessage,
|
||||
status: 'error' as const,
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: errorMessage,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toContain('icon="triangle-alert"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tooltip behavior', () => {
|
||||
it('should enable tooltip for running status', () => {
|
||||
const runningMessage = {
|
||||
...baseMessage,
|
||||
status: 'running' as const,
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: runningMessage,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
// Check that tooltip is enabled by looking for the actual tooltip attributes
|
||||
expect(wrapper.html()).toContain('icon="spinner"');
|
||||
});
|
||||
|
||||
it('should disable tooltip for non-running status', () => {
|
||||
const completedMessage = {
|
||||
...baseMessage,
|
||||
status: 'completed' as const,
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message: completedMessage,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
// Check that the completed icon is rendered instead of spinner
|
||||
expect(wrapper.html()).toContain('icon="circle-check"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toolDisplayName', () => {
|
||||
it('should prioritize customDisplayTitle', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
customDisplayTitle: 'Custom Title',
|
||||
displayTitle: 'Display Title',
|
||||
toolName: 'tool_name',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Custom Title');
|
||||
});
|
||||
|
||||
it('should use displayTitle when customDisplayTitle is not available', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
displayTitle: 'Display Title',
|
||||
toolName: 'tool_name',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Display Title');
|
||||
});
|
||||
|
||||
it('should convert snake_case toolName to Title Case', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
toolName: 'convert_snake_case_to_title',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Convert Snake Case To Title');
|
||||
});
|
||||
|
||||
it('should handle single word toolName', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
toolName: 'search',
|
||||
};
|
||||
|
||||
const wrapper = mountToolMessage({
|
||||
message,
|
||||
isFirstOfRole: true,
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toContain('Search');
|
||||
});
|
||||
});
|
||||
});
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import BaseMessage from './BaseMessage.vue';
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
import type { ChatUI } from '../../../types/assistant';
|
||||
import N8nIcon from '../../N8nIcon';
|
||||
import N8nText from '../../N8nText';
|
||||
import N8nTooltip from '../../N8nTooltip';
|
||||
|
||||
export interface Props {
|
||||
message: ChatUI.ToolMessage & { id: string; read: boolean };
|
||||
isFirstOfRole: boolean;
|
||||
showProgressLogs?: boolean;
|
||||
user?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const toolDisplayName = computed(() => {
|
||||
// Convert tool names from snake_case to Title Case
|
||||
return (
|
||||
props.message.customDisplayTitle ??
|
||||
props.message.displayTitle ??
|
||||
props.message.toolName
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
switch (props.message.status) {
|
||||
case 'running':
|
||||
return t('assistantChat.builder.toolRunning');
|
||||
case 'error':
|
||||
return t('assistantChat.builder.toolError');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
switch (props.message.status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'error':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
|
||||
<div :class="$style.toolMessage">
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.titleRow">
|
||||
<div :class="$style.status">
|
||||
<N8nTooltip placement="top" :disabled="!statusMessage">
|
||||
<template #content>
|
||||
<span :class="$style.statusText">
|
||||
{{ statusMessage }}
|
||||
</span>
|
||||
</template>
|
||||
<N8nIcon
|
||||
v-if="message.status === 'running'"
|
||||
icon="spinner"
|
||||
spin
|
||||
:color="statusColor"
|
||||
size="large"
|
||||
/>
|
||||
<N8nIcon
|
||||
v-else-if="message.status === 'error'"
|
||||
icon="triangle-alert"
|
||||
:color="statusColor"
|
||||
size="large"
|
||||
/>
|
||||
<N8nIcon v-else icon="circle-check" :color="statusColor" size="large" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<N8nText size="small" bold>{{ toolDisplayName }}</N8nText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseMessage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@use '../../../css/mixins/animations';
|
||||
|
||||
.toolMessage {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing--md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statusText {
|
||||
font-size: var(--font-size--2xs);
|
||||
|
||||
&.status-running {
|
||||
color: var(--execution-card--color--text--waiting);
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
color: var(--color--success);
|
||||
}
|
||||
|
||||
&.status-error {
|
||||
color: var(--color--text--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--spacing--xs) var(--spacing--xs) var(--spacing--xs);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--2xs);
|
||||
color: var(--color--text--shade-1);
|
||||
margin-bottom: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
color: var(--color--danger);
|
||||
font-size: var(--font-size--2xs);
|
||||
padding: var(--spacing--xs);
|
||||
background-color: var(--color--danger--tint-4);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
</style>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MessageRating > should render correctly with default props 1`] = `
|
||||
"<div class="rating">
|
||||
<div class="buttons">
|
||||
<n8n-button-stub label="assistantChat.builder.thumbsUp" icon="thumbs-up" variant="subtle" size="small" loading="false" icononly="false" disabled="false" class="" data-test-id="message-thumbs-up-button"></n8n-button-stub>
|
||||
<n8n-button-stub label="assistantChat.builder.thumbsDown" icon="thumbs-down" variant="subtle" size="small" loading="false" icononly="false" disabled="false" class="" data-test-id="message-thumbs-down-button"></n8n-button-stub>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>"
|
||||
`;
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { ChatUI } from '@n8n/design-system/types';
|
||||
|
||||
import BlockMessage from './BlockMessage.vue';
|
||||
import CodeDiffMessage from './CodeDiffMessage.vue';
|
||||
import ErrorMessage from './ErrorMessage.vue';
|
||||
import EventMessage from './EventMessage.vue';
|
||||
import TextMessage from './TextMessage.vue';
|
||||
import ToolMessage from './ToolMessage.vue';
|
||||
|
||||
export function getSupportedMessageComponent(type: ChatUI.AssistantMessage['type']) {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return TextMessage;
|
||||
case 'block':
|
||||
return BlockMessage;
|
||||
case 'code-diff':
|
||||
return CodeDiffMessage;
|
||||
case 'error':
|
||||
return ErrorMessage;
|
||||
case 'event':
|
||||
return EventMessage;
|
||||
case 'tool':
|
||||
return ToolMessage;
|
||||
case 'thinking-group': // Handled directly in AskAssistantChat.vue
|
||||
case 'agent-suggestion':
|
||||
case 'workflow-updated':
|
||||
case 'custom':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import Markdown from 'markdown-it';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
|
||||
import { useI18n } from '../../../composables/useI18n';
|
||||
|
||||
export function useMarkdown() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const md = new Markdown({
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
md.use(markdownLink, {
|
||||
attrs: {
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
|
||||
function renderMarkdown(content: string) {
|
||||
try {
|
||||
return md.render(content);
|
||||
} catch (e) {
|
||||
console.error(`Error parsing markdown content ${content}`);
|
||||
return `<p>${t('assistantChat.errorParsingMarkdown')}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
renderMarkdown,
|
||||
};
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import AssistantIcon from './AssistantIcon.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AssistantIcon',
|
||||
component: AssistantIcon,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
AssistantIcon,
|
||||
},
|
||||
template: '<div style="background: lightgray;"><AssistantIcon v-bind="args" /></div>',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
theme: 'default',
|
||||
};
|
||||
|
||||
export const Blank = Template.bind({
|
||||
template: '<div style="background=black;"><AssistantIcon v-bind="args" /></div>',
|
||||
});
|
||||
Blank.args = {
|
||||
theme: 'blank',
|
||||
};
|
||||
|
||||
export const Mini = Template.bind({});
|
||||
Mini.args = {
|
||||
size: 'mini',
|
||||
};
|
||||
|
||||
export const Small = Template.bind({});
|
||||
Small.args = {
|
||||
size: 'small',
|
||||
};
|
||||
|
||||
export const Medium = Template.bind({});
|
||||
Medium.args = {
|
||||
size: 'medium',
|
||||
};
|
||||
|
||||
export const Large = Template.bind({});
|
||||
Large.args = {
|
||||
size: 'large',
|
||||
};
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import AssistantIcon from './AssistantIcon.vue';
|
||||
|
||||
describe('AssistantIcon', () => {
|
||||
it('renders default icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders blank icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {
|
||||
props: {
|
||||
theme: 'blank',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders mini icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {
|
||||
props: {
|
||||
size: 'mini',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders small icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders medium icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders large icon correctly', () => {
|
||||
const { container } = render(AssistantIcon, {
|
||||
props: {
|
||||
size: 'large',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: 'mini' | 'small' | 'medium' | 'large';
|
||||
theme?: 'default' | 'blank' | 'disabled';
|
||||
}>(),
|
||||
{
|
||||
size: 'medium',
|
||||
theme: 'default',
|
||||
},
|
||||
);
|
||||
|
||||
const sizes = {
|
||||
mini: 8,
|
||||
small: 10,
|
||||
medium: 12,
|
||||
large: 18,
|
||||
};
|
||||
|
||||
const svgFill = computed(() => {
|
||||
if (props.theme === 'blank') {
|
||||
return 'white';
|
||||
} else if (props.theme === 'disabled') {
|
||||
return 'var(--color--text--tint-1)';
|
||||
}
|
||||
return 'url(#paint0_linear_173_12825)';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="sizes[size]"
|
||||
:height="sizes[size]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
:fill="svgFill"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
y1="-0.000120994"
|
||||
x2="28.8315"
|
||||
y2="9.82667"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--assistant--color--highlight-1)" />
|
||||
<stop offset="0.495" stop-color="var(--assistant--color--highlight-2)" />
|
||||
<stop offset="1" stop-color="var(--assistant--color--highlight-3)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AssistantIcon > renders blank icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantIcon > renders default icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantIcon > renders large icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
width="18"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantIcon > renders medium icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantIcon > renders mini icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="8"
|
||||
viewBox="0 0 24 24"
|
||||
width="8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantIcon > renders small icon correctly 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
fill="none"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
width="10"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="url(#paint0_linear_173_12825)"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AssistantIcon from './AssistantIcon.vue';
|
||||
|
||||
export default AssistantIcon;
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import AssistantText from './AssistantText.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/AssistantText',
|
||||
component: AssistantText,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
AssistantText,
|
||||
},
|
||||
template: '<AssistantText v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
text: 'Ask me something!!!',
|
||||
};
|
||||
|
||||
export const Small = Template.bind({});
|
||||
Small.args = {
|
||||
text: 'Ask me something!!!',
|
||||
size: 'small',
|
||||
};
|
||||
|
||||
export const Medium = Template.bind({});
|
||||
Medium.args = {
|
||||
text: 'Ask me something!!!',
|
||||
size: 'medium',
|
||||
};
|
||||
|
||||
export const Large = Template.bind({});
|
||||
Large.args = {
|
||||
text: 'Ask me something!!!',
|
||||
size: 'large',
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import AssistantText from './AssistantText.vue';
|
||||
|
||||
describe('AssistantText', () => {
|
||||
it('renders default text correctly', () => {
|
||||
const { container } = render(AssistantText, {
|
||||
props: {
|
||||
text: 'Ask me something',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders small text correctly', () => {
|
||||
const { container } = render(AssistantText, {
|
||||
props: {
|
||||
text: 'Ask me something',
|
||||
size: 'small',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders medium text correctly', () => {
|
||||
const { container } = render(AssistantText, {
|
||||
props: {
|
||||
text: 'Ask me something',
|
||||
size: 'medium',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it('renders large text correctly', () => {
|
||||
const { container } = render(AssistantText, {
|
||||
props: {
|
||||
text: 'Ask me something',
|
||||
size: 'large',
|
||||
},
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ text: string; size?: 'small' | 'medium' | 'large' | 'xlarge' }>(), {
|
||||
text: '',
|
||||
size: 'medium',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="[$style.text, $style[size]]">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.text {
|
||||
background: var(--assistant--color--highlight-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-weight: var(--font-weight--bold);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 9px;
|
||||
line-height: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.medium {
|
||||
font-size: var(--spacing--xs);
|
||||
line-height: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.xlarge {
|
||||
font-size: var(--spacing--sm);
|
||||
line-height: var(--spacing--sm);
|
||||
}
|
||||
</style>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AssistantText > renders default text correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="text medium"
|
||||
>
|
||||
Ask me something
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantText > renders large text correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="text large"
|
||||
>
|
||||
Ask me something
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantText > renders medium text correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="text medium"
|
||||
>
|
||||
Ask me something
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AssistantText > renders small text correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="text small"
|
||||
>
|
||||
Ask me something
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import AssistantText from './AssistantText.vue';
|
||||
|
||||
export default AssistantText;
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import BetaTag from './BetaTag.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/BetaTag',
|
||||
component: BetaTag,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
BetaTag,
|
||||
},
|
||||
template: '<BetaTag v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Beta = Template.bind({});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import BetaTag from './BetaTag.vue';
|
||||
|
||||
describe('BetaTag', () => {
|
||||
it('renders beta tag correctly', () => {
|
||||
const { container } = render(BetaTag, {});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.beta">{{ t('betaTag.beta') }}</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.beta {
|
||||
display: inline-block;
|
||||
|
||||
color: var(--color--secondary);
|
||||
font-size: var(--font-size--3xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
background-color: var(--color--secondary--tint-2);
|
||||
padding: var(--spacing--5xs) var(--spacing--4xs);
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`BetaTag > renders beta tag correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="beta"
|
||||
>
|
||||
beta
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import BlinkingCursor from './BlinkingCursor.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/BlinkingCursor',
|
||||
component: BlinkingCursor,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
BlinkingCursor,
|
||||
},
|
||||
template: '<blinking-cursor v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Cursor = Template.bind({});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import BlinkingCursor from './BlinkingCursor.vue';
|
||||
|
||||
describe('BlinkingCursor', () => {
|
||||
it('renders blinking cursor correctly', () => {
|
||||
const { container } = render(BlinkingCursor, {});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<span class="blinking-cursor"></span>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.blinking-cursor {
|
||||
display: inline-block;
|
||||
height: var(--font-size--md);
|
||||
width: var(--spacing--3xs);
|
||||
border-radius: var(--radius--sm);
|
||||
margin-left: var(--spacing--4xs);
|
||||
|
||||
animation: 1s blink step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
from,
|
||||
to {
|
||||
background-color: transparent;
|
||||
}
|
||||
50% {
|
||||
background-color: var(--color--foreground--shade-2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`BlinkingCursor > renders blinking cursor correctly 1`] = `
|
||||
<div>
|
||||
<span
|
||||
class="blinking-cursor"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import N8nCanvasPill from '../CanvasPill';
|
||||
import N8nAvatar from '../N8nAvatar';
|
||||
import N8nButton from '../N8nButton';
|
||||
|
||||
defineOptions({
|
||||
name: 'N8nCanvasCollaborationPill',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
isAnotherTab?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
buttonClick: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const userName = computed(() => {
|
||||
return props.lastName ? `${props.firstName} ${props.lastName}` : props.firstName;
|
||||
});
|
||||
|
||||
const message = computed(() => {
|
||||
return props.isAnotherTab
|
||||
? t('collaboration.canvas.editingAnotherTab')
|
||||
: t('collaboration.canvas.editing', { user: userName.value });
|
||||
});
|
||||
|
||||
const handleButtonClick = () => {
|
||||
emit('buttonClick');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCanvasPill>
|
||||
<template #icon>
|
||||
<N8nAvatar v-if="!isAnotherTab" :first-name="firstName" :last-name="lastName" size="small" />
|
||||
</template>
|
||||
<span :class="$style.content">
|
||||
{{ message }}
|
||||
<N8nButton v-if="isAnotherTab" variant="subtle" size="xsmall" @click="handleButtonClick">
|
||||
{{ t('collaboration.canvas.acquireEditing') }}
|
||||
</N8nButton>
|
||||
</span>
|
||||
</N8nCanvasPill>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import N8nCanvasCollaborationPill from './CanvasCollaborationPill.vue';
|
||||
|
||||
export default N8nCanvasCollaborationPill;
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { useCssModule } from 'vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'N8nCanvasPill',
|
||||
});
|
||||
|
||||
const $style = useCssModule();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.pill">
|
||||
<slot name="icon" />
|
||||
<span :class="$style.text">
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.pill {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing--sm) 0 var(--spacing--xs);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
border-radius: 22px;
|
||||
border: var(--border-width) solid var(--color--neutral-850);
|
||||
background: rgba(65, 66, 68, 0.92);
|
||||
cursor: default;
|
||||
|
||||
// Disable text selection
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: white;
|
||||
font-size: var(--font-size--sm);
|
||||
font-weight: var(--font-weight--medium);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
import N8nCanvasPill from './CanvasPill.vue';
|
||||
|
||||
export default N8nCanvasPill;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import N8nCanvasThinkingPill from './CanvasThinkingPill.vue';
|
||||
|
||||
describe('N8nCanvasThinkingPill', () => {
|
||||
it('renders canvas thinking pill correctly', () => {
|
||||
const { container } = render(N8nCanvasThinkingPill, {});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useCssModule } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue';
|
||||
import N8nCanvasPill from '../CanvasPill';
|
||||
import N8nButton from '../N8nButton';
|
||||
|
||||
defineOptions({
|
||||
name: 'N8nCanvasThinkingPill',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
showStop?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
stop: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const $style = useCssModule();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCanvasPill>
|
||||
<template #icon>
|
||||
<div :class="$style.iconWrapper">
|
||||
<AssistantIcon theme="blank" />
|
||||
</div>
|
||||
</template>
|
||||
<div :class="$style.wrapper">
|
||||
{{ t('aiAssistant.builder.canvas.thinking') }}
|
||||
<N8nButton
|
||||
v-if="showStop"
|
||||
:class="$style.stopButton"
|
||||
:label="'Stop'"
|
||||
variant="ghost"
|
||||
size="xsmall"
|
||||
@click="emit('stop')"
|
||||
/>
|
||||
</div>
|
||||
</N8nCanvasPill>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.iconWrapper {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(83.1% 83.1% at 30% 30%, #7a6bea 0%, #d57bae 100%);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stopButton {
|
||||
margin-left: var(--spacing--xs);
|
||||
margin-right: calc(var(--spacing--2xs) * -1);
|
||||
color: var(--color--neutral-white);
|
||||
}
|
||||
</style>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`N8nCanvasThinkingPill > renders canvas thinking pill correctly 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="pill"
|
||||
>
|
||||
|
||||
<div
|
||||
class="iconWrapper"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
width="12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<lineargradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id="paint0_linear_173_12825"
|
||||
x1="-3.67094e-07"
|
||||
x2="28.8315"
|
||||
y1="-0.000120994"
|
||||
y2="9.82667"
|
||||
>
|
||||
<stop
|
||||
stop-color="var(--assistant--color--highlight-1)"
|
||||
/>
|
||||
<stop
|
||||
offset="0.495"
|
||||
stop-color="var(--assistant--color--highlight-2)"
|
||||
/>
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="var(--assistant--color--highlight-3)"
|
||||
/>
|
||||
</lineargradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="text"
|
||||
>
|
||||
|
||||
<div
|
||||
class="wrapper"
|
||||
>
|
||||
Working...
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import N8nCanvasThinkingPill from './CanvasThinkingPill.vue';
|
||||
|
||||
export default N8nCanvasThinkingPill;
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
|
||||
import CodeDiff from './CodeDiff.vue';
|
||||
|
||||
export default {
|
||||
title: 'Assistant/CodeDiff',
|
||||
component: CodeDiff,
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const methods = {};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
CodeDiff,
|
||||
},
|
||||
template: '<div style="width:300px; height:100%"><code-diff v-bind="args" /></div>',
|
||||
methods,
|
||||
});
|
||||
|
||||
export const Example = Template.bind({});
|
||||
Example.args = {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
};
|
||||
|
||||
export const Empty = Template.bind({});
|
||||
Empty.args = {};
|
||||
|
||||
export const Code = Template.bind({});
|
||||
Code.args = {
|
||||
title: "Fix reference to the node and remove unsupported 'require' statement.",
|
||||
content:
|
||||
"--- original.js\n+++ modified.js\n@@ -1,2 +1,2 @@\n-const SIGNING_SECRET = $input.first().json.slack_secret_signature;\n-const item = $('Webhook to call for Slack command').first();\n+const SIGNING_SECRET = items[0].json.slack_secret_signature;\n+const item = items[0];\n@@ -7,8 +7,6 @@\n}\n\n-const crypto = require('crypto');\n-\n const { binary: { data } } = item;\n\n if (\n@@ -22,7 +20,7 @@\n const rawBody = Buffer.from(data.data, 'base64').toString()\n \n // compute the ",
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const StreamingTitleEmpty = Template.bind({});
|
||||
StreamingTitleEmpty.args = {
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const StreamingTitle = Template.bind({});
|
||||
StreamingTitle.args = {
|
||||
streaming: true,
|
||||
title: 'Hello world',
|
||||
};
|
||||
|
||||
export const StreamingContentWithOneLine = Template.bind({});
|
||||
StreamingContentWithOneLine.args = {
|
||||
streaming: true,
|
||||
title: 'Hello world',
|
||||
content: '@@ -1,7 +1,6 @@\n-The Way that can be told of is not th',
|
||||
};
|
||||
|
||||
export const StreamingContentWithMultipleLines = Template.bind({});
|
||||
StreamingContentWithMultipleLines.args = {
|
||||
streaming: true,
|
||||
title: 'Hello world',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can b',
|
||||
};
|
||||
|
||||
export const StreamingWithManyManyLines = Template.bind({});
|
||||
StreamingWithManyManyLines.args = {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
streaming: true,
|
||||
};
|
||||
|
||||
export const Replaced = Template.bind({});
|
||||
Replaced.args = {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
replaced: true,
|
||||
};
|
||||
|
||||
export const Replacing = Template.bind({});
|
||||
Replacing.args = {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
replacing: true,
|
||||
};
|
||||
|
||||
export const Error = Template.bind({});
|
||||
Error.args = {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
error: true,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import CodeDiff from './CodeDiff.vue';
|
||||
|
||||
const stubs = ['N8nButton', 'N8nIcon'];
|
||||
|
||||
describe('CodeDiff', () => {
|
||||
it('renders code diff correctly', () => {
|
||||
const { container } = render(CodeDiff, {
|
||||
props: {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
"--- original.js\n+++ modified.js\n@@ -1,2 +1,2 @@\n-const SIGNING_SECRET = $input.first().json.slack_secret_signature;\n-const item = $('Webhook to call for Slack command').first();\n+const SIGNING_SECRET = items[0].json.slack_secret_signature;\n+const item = items[0];\n@@ -7,8 +7,6 @@\n}\n\n-const crypto = require('crypto');\n-\n const { binary: { data } } = item;\n\n if (\n@@ -22,7 +20,7 @@\n const rawBody = Buffer.from(data.data, 'base64').toString()\n \n // compute the ",
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders replaced code diff correctly', () => {
|
||||
const { container } = render(CodeDiff, {
|
||||
props: {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
replaced: true,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders replacing code diff correctly', () => {
|
||||
const { container } = render(CodeDiff, {
|
||||
props: {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
replacing: true,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders error state correctly', () => {
|
||||
const { container } = render(CodeDiff, {
|
||||
props: {
|
||||
title: 'Lao Tzu example unified diff',
|
||||
content:
|
||||
'@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!',
|
||||
error: true,
|
||||
},
|
||||
global: { stubs },
|
||||
});
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import parseDiff from 'parse-diff';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from '../../composables/useI18n';
|
||||
import N8nButton from '../N8nButton';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
|
||||
const MIN_LINES = 4;
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
content?: string;
|
||||
replacing?: boolean;
|
||||
replaced?: boolean;
|
||||
error?: boolean;
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| parseDiff.Change
|
||||
| {
|
||||
type: 'filler' | 'seperator';
|
||||
content: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
content: '',
|
||||
replacing: false,
|
||||
replaced: false,
|
||||
error: false,
|
||||
streaming: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
replace: [];
|
||||
undo: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const diffs = computed(() => {
|
||||
const parsed = parseDiff(props.content);
|
||||
|
||||
const file = parsed[0] ?? { chunks: [] };
|
||||
|
||||
const lines: Line[] = file.chunks.reduce((accu: Line[], chunk, i) => {
|
||||
const changes: Line[] = chunk.changes.map((change) => {
|
||||
let content = change.content;
|
||||
if (change.type === 'add' && content.startsWith('+')) {
|
||||
content = content.replace('+', '');
|
||||
} else if (change.type === 'del' && content.startsWith('-')) {
|
||||
content = content.replace('-', '');
|
||||
}
|
||||
|
||||
return {
|
||||
...change,
|
||||
content,
|
||||
};
|
||||
});
|
||||
|
||||
if (i !== file.chunks.length - 1) {
|
||||
changes.push({
|
||||
type: 'seperator',
|
||||
content: '...',
|
||||
});
|
||||
}
|
||||
return [...accu, ...changes];
|
||||
}, []);
|
||||
|
||||
const len = lines.length;
|
||||
// why programmatic and not min height? to ensure numbers border goes all the way down.
|
||||
if (len <= MIN_LINES) {
|
||||
for (let i = 0; i < MIN_LINES - len; i++) {
|
||||
lines.push({
|
||||
type: 'filler',
|
||||
content: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="code-diff-suggestion">
|
||||
<div :class="$style.title">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div :class="$style.diffSection">
|
||||
<div v-for="(diff, i) in diffs" :key="i" :class="$style.diff">
|
||||
<div :class="$style.lineNumber">
|
||||
<!-- ln1 is line number in original text -->
|
||||
<!-- ln2 is line number in updated text -->
|
||||
{{ diff.type === 'normal' ? diff.ln2 : diff.type === 'add' ? diff.ln : '' }}
|
||||
</div>
|
||||
<div :class="[$style[diff.type], $style.diffContent]">
|
||||
<span :class="$style.indicator">{{
|
||||
diff.type === 'add' ? '+' : diff.type === 'del' ? '-' : ''
|
||||
}}</span>
|
||||
<span>
|
||||
{{ diff.content }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<div v-if="error">
|
||||
<N8nIcon icon="triangle-alert" color="danger" class="mr-5xs" />
|
||||
<span :class="$style.infoText">{{ t('codeDiff.couldNotReplace') }}</span>
|
||||
</div>
|
||||
<div v-else-if="replaced">
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
size="xsmall"
|
||||
icon="undo-2"
|
||||
data-test-id="undo-replace-button"
|
||||
@click="() => emit('undo')"
|
||||
>
|
||||
{{ t('codeDiff.undo') }}
|
||||
</N8nButton>
|
||||
<N8nIcon icon="check" color="success" class="ml-xs" />
|
||||
<span :class="$style.infoText" data-test-id="code-replaced-message">
|
||||
{{ t('codeDiff.codeReplaced') }}
|
||||
</span>
|
||||
</div>
|
||||
<N8nButton
|
||||
v-else
|
||||
:variant="replacing ? 'subtle' : 'solid'"
|
||||
size="xsmall"
|
||||
icon="refresh-cw"
|
||||
data-test-id="replace-code-button"
|
||||
:disabled="!content || streaming"
|
||||
:loading="replacing"
|
||||
@click="() => emit('replace')"
|
||||
>{{ replacing ? t('codeDiff.replacing') : t('codeDiff.replaceMyCode') }}</N8nButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
border: var(--border);
|
||||
background-color: var(--color--foreground--tint-2);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: var(--spacing--2xs);
|
||||
font-weight: var(--font-weight--bold);
|
||||
font-size: var(--font-size--2xs);
|
||||
// ensure consistent spacing even if title is empty
|
||||
min-height: 32.5px;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lineNumber {
|
||||
font-size: var(--font-size--3xs);
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
text-align: center;
|
||||
border-right: var(--border);
|
||||
}
|
||||
|
||||
.diffSection {
|
||||
overflow: auto;
|
||||
border-top: var(--border);
|
||||
border-bottom: var(--border);
|
||||
max-height: 218px; // 12 lines
|
||||
background-color: var(--color--background);
|
||||
font-family: var(--font-family--monospace);
|
||||
}
|
||||
|
||||
.diff {
|
||||
display: flex;
|
||||
font-size: var(--font-size--3xs);
|
||||
line-height: 18px; /* 100% */
|
||||
height: 18px;
|
||||
max-height: 18px;
|
||||
}
|
||||
|
||||
.diffContent {
|
||||
width: auto;
|
||||
text-wrap: nowrap;
|
||||
display: flex;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.add {
|
||||
color: var(--diff--color--new);
|
||||
background-color: var(--diff--color--new--faint);
|
||||
}
|
||||
|
||||
.del {
|
||||
color: var(--diff--color--deleted);
|
||||
background-color: var(--diff--color--deleted--faint);
|
||||
}
|
||||
|
||||
.normal {
|
||||
background-color: var(--color--background);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
padding: var(--spacing--2xs);
|
||||
|
||||
> button {
|
||||
> span {
|
||||
margin-right: var(--spacing--4xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.infoText {
|
||||
color: var(--color--text--tint-1);
|
||||
font-size: var(--font-size--xs);
|
||||
margin-left: var(--spacing--4xs);
|
||||
}
|
||||
</style>
|
||||
+1155
File diff suppressed because it is too large
Load Diff
+68
@@ -0,0 +1,68 @@
|
||||
import { render } from '@testing-library/vue';
|
||||
import { beforeAll, describe } from 'vitest';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import CondtionalRouterLink from './CondtionalRouterLink.vue';
|
||||
|
||||
const slots = {
|
||||
default: 'Button',
|
||||
};
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: '/home',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('CondtionalRouterLink', () => {
|
||||
beforeAll(async () => {
|
||||
await router.push('/');
|
||||
|
||||
await router.isReady();
|
||||
});
|
||||
|
||||
it("renders router-link when 'to' prop is passed", () => {
|
||||
const wrapper = render(CondtionalRouterLink, {
|
||||
props: {
|
||||
to: { name: 'home' },
|
||||
},
|
||||
slots,
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders <a> when 'href' attr is passed", () => {
|
||||
const wrapper = render(CondtionalRouterLink, {
|
||||
attrs: {
|
||||
href: 'https://n8n.io',
|
||||
target: '_blank',
|
||||
},
|
||||
slots,
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders only the slot when neither to nor href is given', () => {
|
||||
const wrapper = render(CondtionalRouterLink, {
|
||||
slots,
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Component that renders either a RouterLink or a normal anchor tag or
|
||||
* just the slot content based on whether the `to` or `href` prop is
|
||||
* passed or not.
|
||||
*/
|
||||
import { useAttrs } from 'vue';
|
||||
import type { RouterLinkProps } from 'vue-router';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
defineOptions({
|
||||
name: 'ConditionalRouterLink',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
// @ts-expect-error TS doesn't understand this but it works
|
||||
...RouterLink.props,
|
||||
// Make to optional
|
||||
to: {
|
||||
type: [String, Object] as unknown as () => string | RouterLinkProps['to'] | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
// <a> element "props" are passed as attributes
|
||||
}) as Partial<RouterLinkProps>;
|
||||
const attrs = useAttrs();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<RouterLink v-if="props.to" v-bind="props" :to="props.to">
|
||||
<slot />
|
||||
</RouterLink>
|
||||
<a v-else-if="attrs.href" v-bind="attrs">
|
||||
<slot />
|
||||
</a>
|
||||
<slot v-else />
|
||||
</div>
|
||||
</template>
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`CondtionalRouterLink > renders <a> when 'href' attr is passed 1`] = `"<div><a href="https://n8n.io" target="_blank">Button</a></div>"`;
|
||||
|
||||
exports[`CondtionalRouterLink > renders only the slot when neither to nor href is given 1`] = `"<div>Button</div>"`;
|
||||
|
||||
exports[`CondtionalRouterLink > renders router-link when 'to' prop is passed 1`] = `"<div><a href="/" class="">Button</a></div>"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CondtionalRouterLink from './CondtionalRouterLink.vue';
|
||||
|
||||
export default CondtionalRouterLink;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
||||
|
||||
import DateRangePicker from './DateRangePicker.vue';
|
||||
|
||||
const meta = {
|
||||
component: DateRangePicker,
|
||||
title: 'Experimental/DateRangePicker',
|
||||
} satisfies Meta<typeof DateRangePicker>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import { CalendarDateTime } from '@internationalized/date';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import DateRangePicker from './DateRangePicker.vue';
|
||||
|
||||
async function openCalendarPopover(container: Element) {
|
||||
const trigger = container.querySelector('[data-reka-date-field-segment="trigger"]') as Element;
|
||||
expect(trigger).toBeVisible();
|
||||
|
||||
await userEvent.click(trigger);
|
||||
|
||||
const popoverId = trigger.getAttribute('aria-controls') as string;
|
||||
const popover = document.getElementById(popoverId) as HTMLElement;
|
||||
expect(popover).toBeVisible();
|
||||
|
||||
const anyCell = popover.querySelector('[data-reka-calendar-cell-trigger]');
|
||||
expect(anyCell).toBeVisible();
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
describe('N8nDateRangePicker', () => {
|
||||
it('should show the date range picker when trigger is clicked', async () => {
|
||||
const { container } = render(DateRangePicker);
|
||||
await openCalendarPopover(container);
|
||||
});
|
||||
|
||||
it('should emit update:close when clicking the apply button', async () => {
|
||||
const { container, emitted, getByRole } = render(DateRangePicker);
|
||||
await openCalendarPopover(container);
|
||||
|
||||
const applyButton = getByRole('button', { name: 'Apply' });
|
||||
expect(applyButton).toBeVisible();
|
||||
|
||||
await userEvent.click(applyButton);
|
||||
|
||||
expect(emitted('update:open')).toStrictEqual([
|
||||
[true], //opened
|
||||
[false], // closed by button interaction
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render date inline inputs', async () => {
|
||||
const { container } = render(DateRangePicker);
|
||||
const calendar = await openCalendarPopover(container);
|
||||
|
||||
expect(
|
||||
calendar.querySelectorAll('[data-reka-date-field-segment]:not([data-segment="literal"])')
|
||||
.length,
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it('should render date stacked inputs with time', async () => {
|
||||
const { container } = render(DateRangePicker, {
|
||||
props: {
|
||||
modelValue: {
|
||||
start: new CalendarDateTime(2023, 1, 1, 12, 0, 0),
|
||||
end: new CalendarDateTime(2023, 1, 7, 12, 0, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
const calendar = await openCalendarPopover(container);
|
||||
|
||||
expect(
|
||||
calendar.querySelectorAll('[data-reka-date-field-segment]:not([data-segment="literal"])')
|
||||
.length,
|
||||
).toBe(10);
|
||||
});
|
||||
|
||||
describe('hideInputs prop', () => {
|
||||
it('should not render the input fields when hideInputs is true', async () => {
|
||||
const { container, queryByText } = render(DateRangePicker, {
|
||||
props: { hideInputs: true },
|
||||
});
|
||||
await openCalendarPopover(container);
|
||||
|
||||
expect(queryByText('Apply', { selector: 'button' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('presets slot', () => {
|
||||
it('should render preset buttons when provided via the presets slot', async () => {
|
||||
const { container, getByText } = render(DateRangePicker, {
|
||||
slots: {
|
||||
presets: '<button data-testid="preset-button">Preset 1</button>',
|
||||
},
|
||||
});
|
||||
await openCalendarPopover(container);
|
||||
|
||||
const presetButton = getByText('Preset 1', { selector: 'button' });
|
||||
expect(presetButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
DateRangePickerCalendar,
|
||||
DateRangePickerCell,
|
||||
DateRangePickerCellTrigger,
|
||||
DateRangePickerContent,
|
||||
DateRangePickerGrid,
|
||||
DateRangePickerGridBody,
|
||||
DateRangePickerGridHead,
|
||||
DateRangePickerGridRow,
|
||||
DateRangePickerHeadCell,
|
||||
DateRangePickerHeader,
|
||||
DateRangePickerHeading,
|
||||
DateRangePickerNext,
|
||||
DateRangePickerPrev,
|
||||
DateRangePickerRoot,
|
||||
DateRangePickerTrigger,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui';
|
||||
|
||||
import N8nButton from '../N8nButton';
|
||||
import IconButton from '../N8nIconButton';
|
||||
import N8nDateRangePickerField from './DateRangePickerField.vue';
|
||||
import type { N8nDateRangePickerProps, N8nDateRangePickerRootEmits } from './index';
|
||||
|
||||
const props = withDefaults(defineProps<N8nDateRangePickerProps>(), {
|
||||
weekStartsOn: 1,
|
||||
weekdayFormat: 'short',
|
||||
fixedWeeks: true,
|
||||
hourCycle: 24,
|
||||
});
|
||||
|
||||
const emit = defineEmits<N8nDateRangePickerRootEmits>();
|
||||
|
||||
defineSlots<{
|
||||
presets?: {};
|
||||
trigger?: {};
|
||||
}>();
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emit);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DateRangePickerRoot v-bind="forwarded">
|
||||
<DateRangePickerTrigger as-child>
|
||||
<slot name="trigger">
|
||||
<IconButton variant="subtle" icon="calendar" aria-label="Open calendar" />
|
||||
</slot>
|
||||
</DateRangePickerTrigger>
|
||||
|
||||
<DateRangePickerContent align="start" :side-offset="5" :class="$style.PopoverContent">
|
||||
<DateRangePickerCalendar v-slot="{ weekDays, grid }" :class="$style.Calendar">
|
||||
<template v-if="!!$slots.presets">
|
||||
<div :class="$style.Presets">
|
||||
<slot name="presets" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<div :class="$style.CalendarWrapper">
|
||||
<DateRangePickerHeader :class="$style.CalendarHeader">
|
||||
<DateRangePickerPrev as-child>
|
||||
<IconButton icon="chevron-left" variant="subtle" />
|
||||
</DateRangePickerPrev>
|
||||
<DateRangePickerHeading :class="$style.CalendarHeading" />
|
||||
<DateRangePickerNext as-child>
|
||||
<IconButton icon="chevron-right" variant="subtle" />
|
||||
</DateRangePickerNext>
|
||||
</DateRangePickerHeader>
|
||||
|
||||
<DateRangePickerGrid
|
||||
v-for="month in grid"
|
||||
:key="month.value.toString()"
|
||||
:class="$style.CalendarGrid"
|
||||
>
|
||||
<DateRangePickerGridHead>
|
||||
<DateRangePickerGridRow :class="$style.CalendarGridRow">
|
||||
<DateRangePickerHeadCell
|
||||
v-for="day in weekDays"
|
||||
:key="day"
|
||||
:class="$style.CalendarHeadCell"
|
||||
>
|
||||
{{ day }}
|
||||
</DateRangePickerHeadCell>
|
||||
</DateRangePickerGridRow>
|
||||
</DateRangePickerGridHead>
|
||||
<DateRangePickerGridBody>
|
||||
<DateRangePickerGridRow
|
||||
v-for="(weekDates, index) in month.rows"
|
||||
:key="`weekDate-${index}`"
|
||||
:class="$style.CalendarGridRow"
|
||||
>
|
||||
<DateRangePickerCell
|
||||
v-for="weekDate in weekDates"
|
||||
:key="weekDate.toString()"
|
||||
:date="weekDate"
|
||||
:class="$style.CalendarCell"
|
||||
>
|
||||
<DateRangePickerCellTrigger
|
||||
:day="weekDate"
|
||||
:month="month.value"
|
||||
:class="$style.CalendarCellTrigger"
|
||||
/>
|
||||
</DateRangePickerCell>
|
||||
</DateRangePickerGridRow>
|
||||
</DateRangePickerGridBody>
|
||||
</DateRangePickerGrid>
|
||||
</div>
|
||||
|
||||
<div v-if="!hideInputs" :class="$style.DateFieldWrapper">
|
||||
<N8nDateRangePickerField :class="$style.DateField"></N8nDateRangePickerField>
|
||||
<div :class="$style.DateFieldError">Outside of allowed range</div>
|
||||
|
||||
<N8nButton
|
||||
variant="subtle"
|
||||
label="Apply"
|
||||
class="mt-2xs"
|
||||
:class="$style.ApplyButton"
|
||||
@click="emit('update:open', false)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DateRangePickerCalendar>
|
||||
</DateRangePickerContent>
|
||||
</DateRangePickerRoot>
|
||||
</template>
|
||||
|
||||
<style lang="css" module>
|
||||
.DateFieldWrapper {
|
||||
border-top: 1px solid var(--color--foreground);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.DateField[data-invalid] + .DateFieldError {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.DateFieldError {
|
||||
color: var(--color--danger);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ApplyButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.DateFieldSegment:focus {
|
||||
outline: 2px solid rgba(67, 142, 255, 1);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.Calendar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.CalendarHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.CalendarWrapper {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.CalendarGrid {
|
||||
margin-top: 0.25rem;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.CalendarGridRow {
|
||||
display: grid;
|
||||
margin-bottom: 0.25rem;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.CalendarHeadCell {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--color--text--tint-1);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.CalendarCell {
|
||||
position: relative;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger {
|
||||
display: flex;
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-width: 1px;
|
||||
border-color: transparent;
|
||||
outline-style: none;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size--sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
color: var(--color--text);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-outside-view] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger:hover:not([data-disabled]):not([data-selection-start='true']):not(
|
||||
[data-selection-end='true']
|
||||
) {
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.CalendarCellTrigger:focus {
|
||||
outline: 2px solid rgba(67, 142, 255, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-unavailable] {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-highlighted],
|
||||
.CalendarCellTrigger[data-selected] {
|
||||
background: var(--color--foreground--tint-1);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-selection-start='true'],
|
||||
.CalendarCellTrigger[data-highlighted-start='true'] {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-selection-end='true'],
|
||||
.CalendarCellTrigger[data-highlighted-end='true'] {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-selection-start='true'],
|
||||
.CalendarCellTrigger[data-selection-end='true'] {
|
||||
background: var(--color--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.CalendarCellTrigger[data-today]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--color--primary);
|
||||
}
|
||||
|
||||
.PopoverContent {
|
||||
border-radius: 4px;
|
||||
border: var(--border);
|
||||
background: var(--color--foreground--tint-2);
|
||||
box-shadow: 0 6px 16px 0 rgba(68, 28, 23, 0.06);
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform, opacity;
|
||||
z-index: 9999;
|
||||
}
|
||||
.PopoverContent:focus {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.PopoverContent[data-state='open'][data-side='top'] {
|
||||
animation-name: slideDownAndFade;
|
||||
}
|
||||
.PopoverContent[data-state='open'][data-side='right'] {
|
||||
animation-name: slideLeftAndFade;
|
||||
}
|
||||
.PopoverContent[data-state='open'][data-side='bottom'] {
|
||||
animation-name: slideUpAndFade;
|
||||
}
|
||||
.PopoverContent[data-state='open'][data-side='left'] {
|
||||
animation-name: slideRightAndFade;
|
||||
}
|
||||
|
||||
.Presets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-right: var(--border);
|
||||
}
|
||||
|
||||
@keyframes slideUpAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRightAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDownAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideLeftAndFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { DateRangePickerField, DateRangePickerInput } from 'reka-ui';
|
||||
|
||||
import N8nText from '../N8nText';
|
||||
|
||||
type Segment = {
|
||||
part: string;
|
||||
value: string;
|
||||
};
|
||||
type Segments = {
|
||||
start: Segment[];
|
||||
end: Segment[];
|
||||
};
|
||||
|
||||
function hasTime(segments: Segments) {
|
||||
return [...segments.start, ...segments.end].some((segment) =>
|
||||
['hour', 'minute', 'second', 'dayPeriod'].includes(segment.part),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DateRangePickerField v-slot="{ segments }" as-child>
|
||||
<div v-if="hasTime(segments)">
|
||||
<N8nText bold color="text-light" tag="div" class="mb-3xs">Start</N8nText>
|
||||
<div :class="$style.Inline" class="mb-xs">
|
||||
<template v-for="item in segments.start" :key="item.part">
|
||||
<DateRangePickerInput
|
||||
v-if="item.part === 'literal'"
|
||||
:part="item.part"
|
||||
class="DateFieldLiteral"
|
||||
type="start"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
<DateRangePickerInput
|
||||
v-else
|
||||
:part="item.part"
|
||||
:class="$style.DateFieldSegment"
|
||||
type="start"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
</template>
|
||||
</div>
|
||||
<N8nText bold color="text-light" tag="div" class="mb-3xs">End</N8nText>
|
||||
<div :class="$style.Inline">
|
||||
<template v-for="item in segments.end" :key="item.part">
|
||||
<DateRangePickerInput
|
||||
v-if="item.part === 'literal'"
|
||||
:part="item.part"
|
||||
:class="$style.DateFieldLiteral"
|
||||
type="end"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
<DateRangePickerInput
|
||||
v-else
|
||||
:part="item.part"
|
||||
:class="$style.DateFieldSegment"
|
||||
type="end"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.Inline">
|
||||
<template v-for="item in segments.start" :key="item.part">
|
||||
<DateRangePickerInput
|
||||
v-if="item.part === 'literal'"
|
||||
:part="item.part"
|
||||
class="DateFieldLiteral"
|
||||
type="start"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
<DateRangePickerInput
|
||||
v-else
|
||||
:part="item.part"
|
||||
:class="$style.DateFieldSegment"
|
||||
type="start"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
</template>
|
||||
-
|
||||
<template v-for="item in segments.end" :key="item.part">
|
||||
<DateRangePickerInput
|
||||
v-if="item.part === 'literal'"
|
||||
:part="item.part"
|
||||
:class="$style.DateFieldLiteral"
|
||||
type="end"
|
||||
>
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
<DateRangePickerInput v-else :part="item.part" :class="$style.DateFieldSegment" type="end">
|
||||
{{ item.value }}
|
||||
</DateRangePickerInput>
|
||||
</template>
|
||||
</div>
|
||||
</DateRangePickerField>
|
||||
</template>
|
||||
|
||||
<style lang="css" module>
|
||||
.DateFieldSegment:focus {
|
||||
outline: 2px solid rgba(67, 142, 255, 1);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.Inline {
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
border-radius: 0.25rem;
|
||||
border-width: 1px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
border: var(--border);
|
||||
font-size: var(--font-size--sm);
|
||||
line-height: var(--line-height--xl);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DateRangePickerRootEmits, DateRangePickerRootProps, PopoverRootEmits } from 'reka-ui';
|
||||
|
||||
export { default as N8nDateRangePicker } from './DateRangePicker.vue';
|
||||
|
||||
export type N8nDateRangePickerProps = DateRangePickerRootProps & {
|
||||
/** When true, hides the date input fields and apply button at the bottom of the picker */
|
||||
hideInputs?: boolean;
|
||||
};
|
||||
export type N8nDateRangePickerRootEmits = DateRangePickerRootEmits & PopoverRootEmits;
|
||||
export type { DateRange, DateValue } from 'reka-ui';
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import IconTextButton from './IconTextButton.vue';
|
||||
|
||||
export default {
|
||||
title: 'Core/IconTextButton',
|
||||
component: IconTextButton,
|
||||
argTypes: {
|
||||
icon: {
|
||||
control: 'text',
|
||||
},
|
||||
iconSize: {
|
||||
control: 'select',
|
||||
options: ['xsmall', 'small', 'medium', 'large', 'xlarge', 'xxlarge'],
|
||||
},
|
||||
iconPosition: {
|
||||
control: 'select',
|
||||
options: ['left', 'right'],
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
active: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const methods = {
|
||||
onClick: action('click'),
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args) => ({
|
||||
setup: () => ({ args }),
|
||||
components: {
|
||||
IconTextButton,
|
||||
},
|
||||
template: '<IconTextButton v-bind="args" @click="onClick">Button text</IconTextButton>',
|
||||
methods,
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
icon: 'undo-2',
|
||||
};
|
||||
|
||||
export const IconOnRight = Template.bind({});
|
||||
IconOnRight.args = {
|
||||
icon: 'arrow-up-right',
|
||||
iconPosition: 'right',
|
||||
};
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
Disabled.args = {
|
||||
icon: 'undo-2',
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
export const Active = Template.bind({});
|
||||
Active.args = {
|
||||
icon: 'undo-2',
|
||||
active: true,
|
||||
};
|
||||
|
||||
const AllStatesTemplate: StoryFn = (args) => ({
|
||||
setup: () => ({ args }),
|
||||
components: {
|
||||
IconTextButton,
|
||||
},
|
||||
template: `
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div>
|
||||
<strong>Default:</strong>
|
||||
<IconTextButton v-bind="args" @click="onClick">Restore version</IconTextButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Icon on right:</strong>
|
||||
<IconTextButton v-bind="args" icon="arrow-up-right" icon-position="right" @click="onClick">Show version</IconTextButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Disabled:</strong>
|
||||
<IconTextButton v-bind="args" :disabled="true" @click="onClick">Restore version</IconTextButton>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Active:</strong>
|
||||
<IconTextButton v-bind="args" :active="true" @click="onClick">Restore version</IconTextButton>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
methods,
|
||||
});
|
||||
|
||||
export const AllStates = AllStatesTemplate.bind({});
|
||||
AllStates.args = {
|
||||
icon: 'undo-2',
|
||||
};
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import { render, fireEvent } from '@testing-library/vue';
|
||||
|
||||
import IconTextButton from './IconTextButton.vue';
|
||||
|
||||
const stubs = ['N8nIcon'];
|
||||
|
||||
describe('components', () => {
|
||||
describe('IconTextButton', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'undo-2',
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render icon on the right when iconPosition is right', () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'arrow-up-right',
|
||||
iconPosition: 'right',
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render disabled state', () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'undo-2',
|
||||
disabled: true,
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button?.disabled).toBe(true);
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render active state', () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'undo-2',
|
||||
active: true,
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should emit click event when clicked', async () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'undo-2',
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
const button = wrapper.container.querySelector('button');
|
||||
await fireEvent.click(button!);
|
||||
expect(wrapper.emitted().click).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should have disabled attribute when disabled', () => {
|
||||
const wrapper = render(IconTextButton, {
|
||||
props: {
|
||||
icon: 'undo-2',
|
||||
disabled: true,
|
||||
},
|
||||
slots: {
|
||||
default: 'Button text',
|
||||
},
|
||||
global: {
|
||||
stubs,
|
||||
},
|
||||
});
|
||||
const button = wrapper.container.querySelector('button');
|
||||
expect(button).toHaveAttribute('disabled');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { IconSize } from '../../types/icon';
|
||||
import N8nIcon from '../N8nIcon';
|
||||
import type { IconName } from '../N8nIcon/icons';
|
||||
|
||||
interface Props {
|
||||
icon: IconName;
|
||||
iconSize?: IconSize;
|
||||
iconPosition?: 'left' | 'right';
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
iconSize: 'medium',
|
||||
iconPosition: 'left',
|
||||
disabled: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const buttonRef = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
defineExpose({
|
||||
buttonRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
ref="buttonRef"
|
||||
:class="[$style.button, { [$style.disabled]: disabled, [$style.active]: active }]"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<N8nIcon v-if="iconPosition === 'left'" :icon="icon" :size="iconSize" />
|
||||
<slot />
|
||||
<N8nIcon v-if="iconPosition === 'right'" :icon="icon" :size="iconSize" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--3xs);
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius--lg);
|
||||
padding: var(--spacing--4xs) var(--spacing--2xs);
|
||||
color: var(--color--text--tint-1);
|
||||
font-size: var(--font-size--2xs);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
font-weight: var(--font-weight--medium);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color--foreground--tint-1);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
background-color: var(--color--foreground);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`components > IconTextButton > should render active state 1`] = `
|
||||
"<button class="button active" type="button">
|
||||
<n8n-icon-stub icon="undo-2" size="medium" spin="false"></n8n-icon-stub>Button text
|
||||
<!--v-if-->
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`components > IconTextButton > should render correctly with default props 1`] = `
|
||||
"<button class="button" type="button">
|
||||
<n8n-icon-stub icon="undo-2" size="medium" spin="false"></n8n-icon-stub>Button text
|
||||
<!--v-if-->
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`components > IconTextButton > should render disabled state 1`] = `
|
||||
"<button class="button disabled" type="button" disabled="">
|
||||
<n8n-icon-stub icon="undo-2" size="medium" spin="false"></n8n-icon-stub>Button text
|
||||
<!--v-if-->
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`components > IconTextButton > should render icon on the right when iconPosition is right 1`] = `
|
||||
"<button class="button" type="button">
|
||||
<!--v-if-->Button text<n8n-icon-stub icon="arrow-up-right" size="medium" spin="false"></n8n-icon-stub>
|
||||
</button>"
|
||||
`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user