mirror of
https://github.com/langgenius/dify.git
synced 2026-04-06 00:12:46 +00:00
Compare commits
4 Commits
feat/evalu
...
codex/dify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
142f94e27a | ||
|
|
a1bd929b3c | ||
|
|
ffb9ee3e36 | ||
|
|
485586f49a |
@@ -77,7 +77,7 @@ if $web_modified; then
|
||||
fi
|
||||
|
||||
cd ./web || exit 1
|
||||
pnpm exec vp staged
|
||||
vp staged
|
||||
|
||||
if $web_ts_modified; then
|
||||
echo "Running TypeScript type-check:tsgo"
|
||||
|
||||
@@ -8,7 +8,7 @@ from hashlib import sha256
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -1392,10 +1392,10 @@ class RegisterService:
|
||||
db.session.add(dify_setup)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.execute(delete(DifySetup))
|
||||
db.session.execute(delete(TenantAccountJoin))
|
||||
db.session.execute(delete(Account))
|
||||
db.session.execute(delete(Tenant))
|
||||
db.session.query(DifySetup).delete()
|
||||
db.session.query(TenantAccountJoin).delete()
|
||||
db.session.query(Account).delete()
|
||||
db.session.query(Tenant).delete()
|
||||
db.session.commit()
|
||||
|
||||
logger.exception("Setup account failed, email: %s, name: %s", email, name)
|
||||
@@ -1496,11 +1496,7 @@ class RegisterService:
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
else:
|
||||
TenantService.check_member_permission(tenant, inviter, account, "add")
|
||||
ta = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
|
||||
.limit(1)
|
||||
)
|
||||
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
|
||||
|
||||
if not ta:
|
||||
TenantService.create_tenant_member(tenant, account, role)
|
||||
@@ -1557,18 +1553,21 @@ class RegisterService:
|
||||
if not invitation_data:
|
||||
return None
|
||||
|
||||
tenant = db.session.scalar(
|
||||
select(Tenant).where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal").limit(1)
|
||||
tenant = (
|
||||
db.session.query(Tenant)
|
||||
.where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not tenant:
|
||||
return None
|
||||
|
||||
tenant_account = db.session.execute(
|
||||
select(Account, TenantAccountJoin.role)
|
||||
tenant_account = (
|
||||
db.session.query(Account, TenantAccountJoin.role)
|
||||
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
|
||||
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
|
||||
).first()
|
||||
.first()
|
||||
)
|
||||
|
||||
if not tenant_account:
|
||||
return None
|
||||
|
||||
@@ -4,8 +4,6 @@ import uuid
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from typing import TypedDict
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import NotFound
|
||||
@@ -25,27 +23,6 @@ from tasks.annotation.enable_annotation_reply_task import enable_annotation_repl
|
||||
from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task
|
||||
|
||||
|
||||
class AnnotationJobStatusDict(TypedDict):
|
||||
job_id: str
|
||||
job_status: str
|
||||
|
||||
|
||||
class EmbeddingModelDict(TypedDict):
|
||||
embedding_provider_name: str
|
||||
embedding_model_name: str
|
||||
|
||||
|
||||
class AnnotationSettingDict(TypedDict):
|
||||
id: str
|
||||
enabled: bool
|
||||
score_threshold: float
|
||||
embedding_model: EmbeddingModelDict | dict
|
||||
|
||||
|
||||
class AnnotationSettingDisabledDict(TypedDict):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AppAnnotationService:
|
||||
@classmethod
|
||||
def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation:
|
||||
@@ -108,7 +85,7 @@ class AppAnnotationService:
|
||||
return annotation
|
||||
|
||||
@classmethod
|
||||
def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict:
|
||||
def enable_app_annotation(cls, args: dict, app_id: str):
|
||||
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
|
||||
cache_result = redis_client.get(enable_app_annotation_key)
|
||||
if cache_result is not None:
|
||||
@@ -132,7 +109,7 @@ class AppAnnotationService:
|
||||
return {"job_id": job_id, "job_status": "waiting"}
|
||||
|
||||
@classmethod
|
||||
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
|
||||
def disable_app_annotation(cls, app_id: str):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
|
||||
cache_result = redis_client.get(disable_app_annotation_key)
|
||||
@@ -590,7 +567,7 @@ class AppAnnotationService:
|
||||
db.session.commit()
|
||||
|
||||
@classmethod
|
||||
def get_app_annotation_setting_by_app_id(cls, app_id: str) -> AnnotationSettingDict | AnnotationSettingDisabledDict:
|
||||
def get_app_annotation_setting_by_app_id(cls, app_id: str):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
# get app info
|
||||
app = (
|
||||
@@ -625,9 +602,7 @@ class AppAnnotationService:
|
||||
return {"enabled": False}
|
||||
|
||||
@classmethod
|
||||
def update_app_annotation_setting(
|
||||
cls, app_id: str, annotation_setting_id: str, args: dict
|
||||
) -> AnnotationSettingDict:
|
||||
def update_app_annotation_setting(cls, app_id: str, annotation_setting_id: str, args: dict):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
# get app info
|
||||
app = (
|
||||
|
||||
@@ -1034,7 +1034,7 @@ class TestRegisterService:
|
||||
)
|
||||
|
||||
# Verify rollback operations were called
|
||||
mock_db_dependencies["db"].session.execute.assert_called()
|
||||
mock_db_dependencies["db"].session.query.assert_called()
|
||||
|
||||
# ==================== Registration Tests ====================
|
||||
|
||||
@@ -1599,8 +1599,10 @@ class TestRegisterService:
|
||||
mock_session_class.return_value.__exit__.return_value = None
|
||||
mock_lookup.return_value = mock_existing_account
|
||||
|
||||
# Mock scalar for TenantAccountJoin lookup - no existing member
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
# Mock the db.session.query for TenantAccountJoin
|
||||
mock_db_query = MagicMock()
|
||||
mock_db_query.filter_by.return_value.first.return_value = None # No existing member
|
||||
mock_db_dependencies["db"].session.query.return_value = mock_db_query
|
||||
|
||||
# Mock TenantService methods
|
||||
with (
|
||||
@@ -1775,9 +1777,14 @@ class TestRegisterService:
|
||||
}
|
||||
mock_get_invitation_by_token.return_value = invitation_data
|
||||
|
||||
# Mock scalar for tenant lookup, execute for account+role lookup
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
# Mock database queries - complex query mocking
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.where.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1809,8 +1816,10 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock scalar for tenant lookup - not found
|
||||
mock_db_dependencies["db"].session.scalar.return_value = None
|
||||
# Mock database queries - no tenant found
|
||||
mock_query = MagicMock()
|
||||
mock_query.filter.return_value.first.return_value = None
|
||||
mock_db_dependencies["db"].session.query.return_value = mock_query
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1833,9 +1842,14 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
|
||||
# Mock database queries
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.filter.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = None # No account found
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
@@ -1861,9 +1875,14 @@ class TestRegisterService:
|
||||
}
|
||||
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
|
||||
|
||||
# Mock scalar for tenant, execute for account+role
|
||||
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
|
||||
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
|
||||
# Mock database queries
|
||||
mock_query1 = MagicMock()
|
||||
mock_query1.filter.return_value.first.return_value = mock_tenant
|
||||
|
||||
mock_query2 = MagicMock()
|
||||
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
|
||||
|
||||
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
|
||||
|
||||
# Execute test
|
||||
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"prepare": "vp config"
|
||||
},
|
||||
"devDependencies": {
|
||||
"taze": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
2
packages/dify-ui/.gitignore
vendored
Normal file
2
packages/dify-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
82
packages/dify-ui/package.json
Normal file
82
packages/dify-ui/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@langgenius/dify-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0-private",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
"./context-menu": {
|
||||
"types": "./dist/context-menu/index.d.ts",
|
||||
"import": "./dist/context-menu/index.js",
|
||||
"default": "./dist/context-menu/index.js"
|
||||
},
|
||||
"./dropdown-menu": {
|
||||
"types": "./dist/dropdown-menu/index.d.ts",
|
||||
"import": "./dist/dropdown-menu/index.js",
|
||||
"default": "./dist/dropdown-menu/index.js"
|
||||
},
|
||||
"./tailwind-preset": {
|
||||
"types": "./dist/tailwind-preset.d.ts",
|
||||
"import": "./dist/tailwind-preset.js",
|
||||
"default": "./dist/tailwind-preset.js"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css",
|
||||
"./markdown.css": "./dist/markdown.css",
|
||||
"./themes/light.css": "./dist/themes/light.css",
|
||||
"./themes/dark.css": "./dist/themes/dark.css",
|
||||
"./themes/manual-light.css": "./dist/themes/manual-light.css",
|
||||
"./themes/manual-dark.css": "./dist/themes/manual-dark.css",
|
||||
"./themes/markdown-light.css": "./dist/themes/markdown-light.css",
|
||||
"./themes/markdown-dark.css": "./dist/themes/markdown-dark.css",
|
||||
"./tokens/tailwind-theme-var-define": {
|
||||
"types": "./dist/tokens/tailwind-theme-var-define.d.ts",
|
||||
"import": "./dist/tokens/tailwind-theme-var-define.js",
|
||||
"default": "./dist/tokens/tailwind-theme-var-define.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build.mjs",
|
||||
"prepack": "pnpm build",
|
||||
"test": "vp test",
|
||||
"test:watch": "vp test --watch",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "catalog:",
|
||||
"@dify/iconify-collections": "workspace:*",
|
||||
"@egoist/tailwindcss-icons": "catalog:",
|
||||
"@iconify-json/heroicons": "catalog:",
|
||||
"@iconify-json/ri": "catalog:",
|
||||
"@remixicon/react": "catalog:",
|
||||
"@tailwindcss/typography": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"tailwind-merge": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react": "catalog:",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"happy-dom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plus": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
31
packages/dify-ui/scripts/build.mjs
Normal file
31
packages/dify-ui/scripts/build.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const distDir = resolve(packageRoot, 'dist')
|
||||
|
||||
await rm(distDir, { recursive: true, force: true })
|
||||
|
||||
const tsc = spawnSync('pnpm', ['exec', 'tsc', '-p', 'tsconfig.build.json'], {
|
||||
cwd: packageRoot,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (tsc.status !== 0)
|
||||
process.exit(tsc.status ?? 1)
|
||||
|
||||
await mkdir(distDir, { recursive: true })
|
||||
|
||||
await cp(resolve(packageRoot, 'src/styles.css'), resolve(packageRoot, 'dist/styles.css'))
|
||||
await cp(resolve(packageRoot, 'src/markdown.css'), resolve(packageRoot, 'dist/markdown.css'))
|
||||
await cp(resolve(packageRoot, 'src/styles'), resolve(packageRoot, 'dist/styles'), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
await cp(resolve(packageRoot, 'src/themes'), resolve(packageRoot, 'dist/themes'), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiFileCopyLine,
|
||||
RiPencilLine,
|
||||
RiShareLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -17,7 +23,7 @@ import {
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from '.'
|
||||
} from './index'
|
||||
|
||||
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
|
||||
<ContextMenuTrigger
|
||||
@@ -185,17 +191,17 @@ export const Complex: Story = {
|
||||
<TriggerArea label="Right-click to inspect all menu capabilities" />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>
|
||||
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiShareLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Share
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
@@ -206,7 +212,7 @@ export const Complex: Story = {
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem destructive>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import type { Placement } from '../internal/placement.js'
|
||||
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
|
||||
import { RiArrowRightSLine, RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '../internal/cn.js'
|
||||
import {
|
||||
menuBackdropClassName,
|
||||
menuGroupLabelClassName,
|
||||
@@ -11,9 +13,8 @@ import {
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
} from '../internal/menu-shared.js'
|
||||
import { parsePlacement } from '../internal/placement.js'
|
||||
|
||||
export const ContextMenu = BaseContextMenu.Root
|
||||
export const ContextMenuTrigger = BaseContextMenu.Trigger
|
||||
@@ -42,11 +43,11 @@ type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'child
|
||||
placement: Placement
|
||||
sideOffset: number
|
||||
alignOffset: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
withBackdrop?: boolean
|
||||
className?: string | undefined
|
||||
popupClassName?: string | undefined
|
||||
positionerProps?: ContextMenuContentProps['positionerProps'] | undefined
|
||||
popupProps?: ContextMenuContentProps['popupProps'] | undefined
|
||||
withBackdrop?: boolean | undefined
|
||||
}
|
||||
|
||||
function renderContextMenuPopup({
|
||||
@@ -173,6 +174,25 @@ export function ContextMenuCheckboxItem({
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ContextMenuItemIndicator({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuIndicatorProps) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <RiCheckLine aria-hidden className="h-4 w-4" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
export function ContextMenuCheckboxItemIndicator({
|
||||
className,
|
||||
...props
|
||||
@@ -182,7 +202,7 @@ export function ContextMenuCheckboxItemIndicator({
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
<RiCheckLine aria-hidden className="h-4 w-4" />
|
||||
</BaseContextMenu.CheckboxItemIndicator>
|
||||
)
|
||||
}
|
||||
@@ -196,7 +216,7 @@ export function ContextMenuRadioItemIndicator({
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
<RiCheckLine aria-hidden className="h-4 w-4" />
|
||||
</BaseContextMenu.RadioItemIndicator>
|
||||
)
|
||||
}
|
||||
@@ -217,20 +237,20 @@ export function ContextMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span aria-hidden className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiArrowRightSLine aria-hidden className="ml-auto size-4 shrink-0 text-text-tertiary" />
|
||||
</BaseContextMenu.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type ContextMenuSubContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: ContextMenuContentProps['positionerProps']
|
||||
popupProps?: ContextMenuContentProps['popupProps']
|
||||
placement?: Placement | undefined
|
||||
sideOffset?: number | undefined
|
||||
alignOffset?: number | undefined
|
||||
className?: string | undefined
|
||||
popupClassName?: string | undefined
|
||||
positionerProps?: ContextMenuContentProps['positionerProps'] | undefined
|
||||
popupProps?: ContextMenuContentProps['popupProps'] | undefined
|
||||
}
|
||||
|
||||
export function ContextMenuSubContent({
|
||||
@@ -278,3 +298,5 @@ export function ContextMenuSeparator({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { Placement }
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Link from '@/next/link'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,20 +13,20 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
children?: ReactNode
|
||||
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
|
||||
function MockLink({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
href: string
|
||||
children?: ReactNode
|
||||
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) {
|
||||
return (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('DropdownMenuContent', () => {
|
||||
@@ -301,7 +300,7 @@ describe('dropdown-menu wrapper', () => {
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLinkItem
|
||||
render={<Link href="/account" />}
|
||||
render={<MockLink href="/account" />}
|
||||
aria-label="account link"
|
||||
>
|
||||
Account settings
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
import {
|
||||
RiArchiveLine,
|
||||
RiChat1Line,
|
||||
RiDeleteBinLine,
|
||||
RiFileCopyLine,
|
||||
RiLink,
|
||||
RiLockLine,
|
||||
RiMailLine,
|
||||
RiPencilLine,
|
||||
RiShareLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -17,7 +28,7 @@ import {
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '.'
|
||||
} from './index'
|
||||
|
||||
const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => (
|
||||
<DropdownMenuTrigger
|
||||
@@ -214,20 +225,20 @@ export const WithIcons: Story = {
|
||||
<TriggerButton />
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiArchiveLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -262,35 +273,35 @@ const ComplexDemo = () => {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroupLabel>Edit</DropdownMenuGroupLabel>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled>
|
||||
<span aria-hidden className="i-ri-lock-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiLockLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Move to Workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiShareLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Share
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiMailLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-chat-1-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiChat1Line aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Slack
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<span aria-hidden className="i-ri-link size-4 shrink-0 text-text-tertiary" />
|
||||
<RiLink aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Copy Link
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
@@ -315,13 +326,13 @@ const ComplexDemo = () => {
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked={showArchived} onCheckedChange={setShowArchived}>
|
||||
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
|
||||
<RiArchiveLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
|
||||
Show Archived
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem destructive>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import type { Placement } from '../internal/placement.js'
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import { RiArrowRightSLine, RiCheckLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '../internal/cn.js'
|
||||
import {
|
||||
menuGroupLabelClassName,
|
||||
menuIndicatorClassName,
|
||||
@@ -10,9 +12,8 @@ import {
|
||||
menuPopupBaseClassName,
|
||||
menuRowClassName,
|
||||
menuSeparatorClassName,
|
||||
} from '@/app/components/base/ui/menu-shared'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
} from '../internal/menu-shared.js'
|
||||
import { parsePlacement } from '../internal/placement.js'
|
||||
|
||||
export const DropdownMenu = Menu.Root
|
||||
export const DropdownMenuTrigger = Menu.Trigger
|
||||
@@ -41,7 +42,7 @@ export function DropdownMenuRadioItemIndicator({
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
<RiCheckLine aria-hidden className="h-4 w-4" />
|
||||
</Menu.RadioItemIndicator>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ export function DropdownMenuCheckboxItemIndicator({
|
||||
className={cn(menuIndicatorClassName, className)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
<RiCheckLine aria-hidden className="h-4 w-4" />
|
||||
</Menu.CheckboxItemIndicator>
|
||||
)
|
||||
}
|
||||
@@ -105,10 +106,10 @@ type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'chi
|
||||
placement: Placement
|
||||
sideOffset: number
|
||||
alignOffset: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps']
|
||||
popupProps?: DropdownMenuContentProps['popupProps']
|
||||
className?: string | undefined
|
||||
popupClassName?: string | undefined
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps'] | undefined
|
||||
popupProps?: DropdownMenuContentProps['popupProps'] | undefined
|
||||
}
|
||||
|
||||
function renderDropdownMenuPopup({
|
||||
@@ -186,20 +187,20 @@ export function DropdownMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
|
||||
<RiArrowRightSLine aria-hidden className="ml-auto size-4 shrink-0 text-text-tertiary" />
|
||||
</Menu.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps']
|
||||
popupProps?: DropdownMenuContentProps['popupProps']
|
||||
placement?: Placement | undefined
|
||||
sideOffset?: number | undefined
|
||||
alignOffset?: number | undefined
|
||||
className?: string | undefined
|
||||
popupClassName?: string | undefined
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps'] | undefined
|
||||
popupProps?: DropdownMenuContentProps['popupProps'] | undefined
|
||||
}
|
||||
|
||||
export function DropdownMenuSubContent({
|
||||
@@ -271,3 +272,5 @@ export function DropdownMenuSeparator({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type { Placement }
|
||||
7
packages/dify-ui/src/internal/cn.ts
Normal file
7
packages/dify-ui/src/internal/cn.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
25
packages/dify-ui/src/internal/placement.ts
Normal file
25
packages/dify-ui/src/internal/placement.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right'
|
||||
type Align = 'start' | 'center' | 'end'
|
||||
|
||||
export type Placement
|
||||
= 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
|
||||
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
|
||||
const [side, align] = placement.split('-') as [Side, Align | undefined]
|
||||
|
||||
return {
|
||||
side,
|
||||
align: align ?? 'center',
|
||||
}
|
||||
}
|
||||
2
packages/dify-ui/src/markdown.css
Normal file
2
packages/dify-ui/src/markdown.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import './themes/markdown-light.css';
|
||||
@import './themes/markdown-dark.css';
|
||||
7
packages/dify-ui/src/styles.css
Normal file
7
packages/dify-ui/src/styles.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@import './themes/light.css' layer(base);
|
||||
@import './themes/dark.css' layer(base);
|
||||
@import './themes/manual-light.css' layer(base);
|
||||
@import './themes/manual-dark.css' layer(base);
|
||||
@import './styles/tokens.css';
|
||||
|
||||
@source './**/*.{js,mjs}';
|
||||
713
packages/dify-ui/src/styles/tokens.css
Normal file
713
packages/dify-ui/src/styles/tokens.css
Normal file
@@ -0,0 +1,713 @@
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@utility system-kbd {
|
||||
/* font define start */
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-2xs-regular-uppercase {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
line-height: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-2xs-regular {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-2xs-medium {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-2xs-medium-uppercase {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
line-height: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-2xs-semibold-uppercase {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
line-height: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-regular {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-regular-uppercase {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-medium {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-medium-uppercase {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-semibold {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xs-semibold-uppercase {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-sm-regular {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-sm-medium {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-sm-medium-uppercase {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-sm-semibold {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-sm-semibold-uppercase {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-md-regular {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-md-medium {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-md-semibold {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-md-semibold-uppercase {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xl-regular {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xl-medium {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility system-xl-semibold {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-xs-regular {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-xs-semibold {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-sm-regular {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-sm-semibold {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-md-regular {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility code-md-semibold {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xs-light {
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xs-regular {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xs-medium {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-sm-light {
|
||||
font-size: 13px;
|
||||
font-weight: 300;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-sm-regular {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-sm-medium {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-md-light {
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-md-regular {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-md-medium {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-lg-light {
|
||||
font-size: 15px;
|
||||
font-weight: 300;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-lg-regular {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-lg-medium {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xl-regular {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xl-medium {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-xl-light {
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
line-height: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-2xl-light {
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-2xl-regular {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility body-2xl-medium {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-xs-semi-bold {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-xs-bold {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-sm-semi-bold {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-sm-bold {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-md-semi-bold {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-md-bold {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-lg-semi-bold {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-lg-bold {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-xl-semi-bold {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-xl-bold {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-2xl-semi-bold {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-2xl-bold {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-3xl-semi-bold {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-3xl-bold {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-4xl-semi-bold {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-4xl-bold {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-5xl-semi-bold {
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-5xl-bold {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-6xl-semi-bold {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-6xl-bold {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-7xl-semi-bold {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-7xl-bold {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-8xl-semi-bold {
|
||||
font-size: 60px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility title-8xl-bold {
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-2xs {
|
||||
/* font define end */
|
||||
|
||||
/* border radius start */
|
||||
border-radius: 2px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-xs {
|
||||
border-radius: 4px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-sm {
|
||||
border-radius: 6px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-md {
|
||||
border-radius: 8px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-lg {
|
||||
border-radius: 10px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-xl {
|
||||
border-radius: 12px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-2xl {
|
||||
border-radius: 16px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-3xl {
|
||||
border-radius: 20px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-4xl {
|
||||
border-radius: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-5xl {
|
||||
border-radius: 24px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-6xl {
|
||||
border-radius: 28px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-7xl {
|
||||
border-radius: 32px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-8xl {
|
||||
border-radius: 40px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-9xl {
|
||||
border-radius: 48px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility radius-full {
|
||||
border-radius: 64px;
|
||||
|
||||
/* border radius end */
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@utility no-spinner {
|
||||
/* Hide arrows from number input */
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { icons as customPublicIcons } from '@dify/iconify-collections/custom-public'
|
||||
import { icons as customVenderIcons } from '@dify/iconify-collections/custom-vender'
|
||||
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
|
||||
import { icons as heroicons } from '@iconify-json/heroicons'
|
||||
import { icons as remixIcons } from '@iconify-json/ri'
|
||||
import { iconsPlugin } from '@egoist/tailwindcss-icons'
|
||||
import tailwindTypography from '@tailwindcss/typography'
|
||||
import tailwindThemeVarDefine from './themes/tailwind-theme-var-define'
|
||||
import tailwindThemeVarDefine from './tokens/tailwind-theme-var-define.js'
|
||||
import typography from './typography.js'
|
||||
|
||||
const config = {
|
||||
@@ -151,7 +153,8 @@ const config = {
|
||||
tailwindTypography,
|
||||
iconsPlugin({
|
||||
collections: {
|
||||
...getIconCollections(['heroicons', 'ri']),
|
||||
heroicons,
|
||||
ri: remixIcons,
|
||||
'custom-public': customPublicIcons,
|
||||
'custom-vender': customVenderIcons,
|
||||
},
|
||||
3
packages/dify-ui/src/typography.d.ts
vendored
Normal file
3
packages/dify-ui/src/typography.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const typography: (helpers: { theme: (path: string) => unknown }) => Record<string, unknown>
|
||||
|
||||
export default typography
|
||||
8
packages/dify-ui/tailwind.config.ts
Normal file
8
packages/dify-ui/tailwind.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import difyUiTailwindPreset from './src/tailwind-preset'
|
||||
|
||||
const config = {
|
||||
content: [],
|
||||
...difyUiTailwindPreset,
|
||||
}
|
||||
|
||||
export default config
|
||||
21
packages/dify-ui/tsconfig.build.json
Normal file
21
packages/dify-ui/tsconfig.build.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.js"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.stories.tsx",
|
||||
"src/**/__tests__/**"
|
||||
]
|
||||
}
|
||||
38
packages/dify-ui/tsconfig.json
Normal file
38
packages/dify-ui/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals",
|
||||
"@testing-library/jest-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"src/**/*.js",
|
||||
"scripts/**/*.mjs",
|
||||
"vite.config.ts",
|
||||
"vitest.setup.ts"
|
||||
]
|
||||
}
|
||||
11
packages/dify-ui/vite.config.ts
Normal file
11
packages/dify-ui/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
},
|
||||
})
|
||||
39
packages/dify-ui/vitest.setup.ts
Normal file
39
packages/dify-ui/vitest.setup.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { afterEach } from 'vitest'
|
||||
|
||||
if (typeof globalThis.ResizeObserver === 'undefined') {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
unobserve() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof globalThis.IntersectionObserver === 'undefined') {
|
||||
globalThis.IntersectionObserver = class {
|
||||
readonly root: Element | Document | null = null
|
||||
readonly rootMargin = ''
|
||||
readonly scrollMargin = ''
|
||||
readonly thresholds: ReadonlyArray<number> = []
|
||||
constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
|
||||
observe(_target: Element) {}
|
||||
unobserve(_target: Element) {}
|
||||
disconnect() {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 563 B |
639
pnpm-lock.yaml
generated
639
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,6 @@ overrides:
|
||||
is-generator-function: npm:@nolyfill/is-generator-function@^1.0.44
|
||||
is-typed-array: npm:@nolyfill/is-typed-array@^1.0.44
|
||||
isarray: npm:@nolyfill/isarray@^1.0.44
|
||||
lodash@>=4.0.0 <= 4.17.23: 4.18.0
|
||||
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
|
||||
object.assign: npm:@nolyfill/object.assign@^1.0.44
|
||||
object.entries: npm:@nolyfill/object.entries@^1.0.44
|
||||
@@ -132,7 +131,6 @@ catalog:
|
||||
"@tanstack/react-form-devtools": 0.2.20
|
||||
"@tanstack/react-query": 5.96.1
|
||||
"@tanstack/react-query-devtools": 5.96.1
|
||||
"@tanstack/react-virtual": 3.13.23
|
||||
"@testing-library/dom": 10.4.1
|
||||
"@testing-library/jest-dom": 6.9.1
|
||||
"@testing-library/react": 16.3.2
|
||||
@@ -148,6 +146,8 @@ catalog:
|
||||
"@types/qs": 6.15.0
|
||||
"@types/react": 19.2.14
|
||||
"@types/react-dom": 19.2.3
|
||||
"@types/react-syntax-highlighter": 15.5.13
|
||||
"@types/react-window": 1.8.8
|
||||
"@types/sortablejs": 1.15.9
|
||||
"@typescript-eslint/eslint-plugin": 8.58.0
|
||||
"@typescript-eslint/parser": 8.58.0
|
||||
@@ -162,7 +162,7 @@ catalog:
|
||||
class-variance-authority: 0.7.1
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1
|
||||
code-inspector-plugin: 1.5.1
|
||||
code-inspector-plugin: 1.5.0
|
||||
copy-to-clipboard: 3.3.3
|
||||
cron-parser: 5.5.0
|
||||
dayjs: 1.11.20
|
||||
@@ -187,7 +187,6 @@ catalog:
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.0
|
||||
happy-dom: 20.8.9
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.10
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
@@ -228,13 +227,14 @@ catalog:
|
||||
react-pdf-highlighter: 8.0.0-rc.0
|
||||
react-server-dom-webpack: 19.2.4
|
||||
react-sortablejs: 6.1.4
|
||||
react-syntax-highlighter: 15.6.6
|
||||
react-textarea-autosize: 8.5.9
|
||||
react-window: 1.8.11
|
||||
reactflow: 11.11.4
|
||||
remark-breaks: 4.0.0
|
||||
remark-directive: 4.0.0
|
||||
scheduler: 0.27.0
|
||||
sharp: 0.34.5
|
||||
shiki: 4.0.2
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.3.4
|
||||
@@ -242,6 +242,7 @@ catalog:
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.5.0
|
||||
tailwindcss: 4.2.2
|
||||
taze: 19.11.0
|
||||
tldts: 7.0.27
|
||||
tsdown: 0.21.7
|
||||
tsx: 4.21.0
|
||||
|
||||
10
taze.config.js
Normal file
10
taze.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'taze'
|
||||
|
||||
export default defineConfig({
|
||||
exclude: [
|
||||
// We are going to replace these
|
||||
'react-syntax-highlighter',
|
||||
'react-window',
|
||||
'@types/react-window',
|
||||
],
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite'
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||
stories: [
|
||||
'../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
'../../packages/dify-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
// Not working with Storybook Vite framework
|
||||
// '@storybook/addon-onboarding',
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const mockVirtualizer = ({
|
||||
count,
|
||||
estimateSize,
|
||||
}: {
|
||||
count: number
|
||||
estimateSize?: (index: number) => number
|
||||
}) => {
|
||||
const getSize = (index: number) => estimateSize?.(index) ?? 0
|
||||
|
||||
return {
|
||||
getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0),
|
||||
getVirtualItems: () => {
|
||||
let start = 0
|
||||
|
||||
return Array.from({ length: count }).map((_, index) => {
|
||||
const size = getSize(index)
|
||||
const virtualItem = {
|
||||
end: start + size,
|
||||
index,
|
||||
key: index,
|
||||
size,
|
||||
start,
|
||||
}
|
||||
|
||||
start += size
|
||||
return virtualItem
|
||||
})
|
||||
},
|
||||
measureElement: vi.fn(),
|
||||
scrollToIndex: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
export { mockVirtualizer as useVirtualizer }
|
||||
@@ -1,11 +0,0 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ appId: string }>
|
||||
}) => {
|
||||
const { appId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="workflow" resourceId={appId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@@ -69,47 +67,40 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}>>([])
|
||||
|
||||
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
|
||||
const navConfig = []
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
const navConfig = [
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
]
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}) => {
|
||||
const { datasetId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@@ -88,30 +86,20 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
return [
|
||||
{
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
disabled: false,
|
||||
},
|
||||
...baseNavigation,
|
||||
]
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
})
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
})
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
|
||||
@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetEvaluationPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,11 +0,0 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,21 +0,0 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -1,7 +0,0 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -165,21 +165,6 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppInfoDetailPanel from '../app-info-detail-panel'
|
||||
|
||||
vi.mock('../../../base/app-icon', () => ({
|
||||
@@ -135,17 +135,6 @@ describe('AppInfoDetailPanel', () => {
|
||||
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
|
||||
})
|
||||
|
||||
it('should not render CardView when app type is evaluation', () => {
|
||||
render(
|
||||
<AppInfoDetailPanel
|
||||
{...defaultProps}
|
||||
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app icon with large size', () => {
|
||||
render(<AppInfoDetailPanel {...defaultProps} />)
|
||||
const icon = screen.getByTestId('app-icon')
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import { getAppModeLabel } from './app-mode-labels'
|
||||
import AppOperations from './app-operations'
|
||||
@@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
|
||||
<ContentDialog
|
||||
show={show}
|
||||
onClose={onClose}
|
||||
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
|
||||
>
|
||||
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
|
||||
<div className="flex items-center gap-3 self-stretch">
|
||||
@@ -109,14 +109,14 @@ const AppInfoDetailPanel = ({
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
|
||||
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">
|
||||
{getAppModeLabel(appDetail.mode, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{appDetail.description && (
|
||||
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
|
||||
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
|
||||
{appDetail.description}
|
||||
</div>
|
||||
)}
|
||||
@@ -126,13 +126,11 @@ const AppInfoDetailPanel = ({
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
{appDetail.type !== AppTypeEnum.EVALUATION && (
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
|
||||
/>
|
||||
)}
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
|
||||
/>
|
||||
{switchOperation && (
|
||||
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
|
||||
<Button
|
||||
@@ -142,7 +140,7 @@ const AppInfoDetailPanel = ({
|
||||
onClick={switchOperation.onClick}
|
||||
>
|
||||
{switchOperation.icon}
|
||||
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
|
||||
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -27,16 +27,12 @@ type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
@@ -108,11 +104,10 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{renderHeader?.(appSidebarExpand)}
|
||||
{!renderHeader && iconType === 'app' && (
|
||||
{iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@@ -141,8 +136,7 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{renderNavigation?.(appSidebarExpand)}
|
||||
{!renderNavigation && navigation.map((item, index) => {
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@@ -262,20 +262,4 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,15 +14,13 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href?: string
|
||||
href: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@@ -31,8 +29,6 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@@ -43,11 +39,8 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@@ -77,32 +70,13 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={linkClassName}
|
||||
className={cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: AppIconSelection
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '✨',
|
||||
background: '#FFFFFF',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon_info: {
|
||||
icon: '✨',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,197 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: AppIconSelection = {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
icon: snippet.icon
|
||||
? {
|
||||
type: 'emoji' as const,
|
||||
icon: snippet.icon,
|
||||
background: snippet.iconBackground || FALLBACK_ICON.background,
|
||||
}
|
||||
: FALLBACK_ICON,
|
||||
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-[400px]">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
|
||||
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
|
||||
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
|
||||
<div className={cn('shrink-0', !expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
icon={snippet.icon}
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && <SnippetInfoDropdown snippet={snippet} />}
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
const resetStore = () => {
|
||||
useStore.setState({
|
||||
appDetail: undefined,
|
||||
appSidebarExpand: '',
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('app store', () => {
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
it('should expose the default state', () => {
|
||||
expect(useStore.getState()).toEqual(expect.objectContaining({
|
||||
appDetail: undefined,
|
||||
appSidebarExpand: '',
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
showPromptLogModal: false,
|
||||
showAgentLogModal: false,
|
||||
showMessageLogModal: false,
|
||||
showAppConfigureFeaturesModal: false,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should update every mutable field through its actions', () => {
|
||||
const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail']
|
||||
const currentLogItem = { id: 'message-1' } as ReturnType<typeof useStore.getState>['currentLogItem']
|
||||
|
||||
useStore.getState().setAppDetail(appDetail)
|
||||
useStore.getState().setAppSidebarExpand('logs')
|
||||
useStore.getState().setCurrentLogItem(currentLogItem)
|
||||
useStore.getState().setCurrentLogModalActiveTab('MESSAGE')
|
||||
useStore.getState().setShowPromptLogModal(true)
|
||||
useStore.getState().setShowAgentLogModal(true)
|
||||
useStore.getState().setShowAppConfigureFeaturesModal(true)
|
||||
|
||||
expect(useStore.getState()).toEqual(expect.objectContaining({
|
||||
appDetail,
|
||||
appSidebarExpand: 'logs',
|
||||
currentLogItem,
|
||||
currentLogModalActiveTab: 'MESSAGE',
|
||||
showPromptLogModal: true,
|
||||
showAgentLogModal: true,
|
||||
showAppConfigureFeaturesModal: true,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should reset the active tab when the message log modal closes', () => {
|
||||
useStore.getState().setCurrentLogModalActiveTab('TRACE')
|
||||
useStore.getState().setShowMessageLogModal(true)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(true)
|
||||
expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE')
|
||||
|
||||
useStore.getState().setShowMessageLogModal(false)
|
||||
|
||||
expect(useStore.getState().showMessageLogModal).toBe(false)
|
||||
expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EditItem, { EditItemType } from '../index'
|
||||
import EditItem, { EditItemType } from './index'
|
||||
|
||||
describe('AddAnnotationModal/EditItem', () => {
|
||||
it('should render query inputs with user avatar and placeholder strings', () => {
|
||||
@@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AddAnnotationModal from '../index'
|
||||
import AddAnnotationModal from './index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import BatchAction from '../batch-action'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
describe('BatchAction', () => {
|
||||
const baseProps = {
|
||||
@@ -1,11 +1,10 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Mock } from 'vitest'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import CSVDownload from '../csv-downloader'
|
||||
import CSVDownload from './csv-downloader'
|
||||
|
||||
const downloaderProps: any[] = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Props } from '../csv-uploader'
|
||||
import type { Props } from './csv-uploader'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import CSVUploader from '../csv-uploader'
|
||||
import CSVUploader from './csv-uploader'
|
||||
|
||||
const toastMocks = vi.hoisted(() => ({
|
||||
notify: vi.fn(),
|
||||
@@ -75,20 +75,6 @@ describe('CSVUploader', () => {
|
||||
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
|
||||
})
|
||||
|
||||
it('should handle drag over and clear dragging state when leaving through the overlay', () => {
|
||||
renderComponent()
|
||||
const { dropZone, dropContainer } = getDropElements()
|
||||
|
||||
fireEvent.dragEnter(dropContainer)
|
||||
const dragLayer = dropContainer.querySelector('.absolute') as HTMLDivElement
|
||||
|
||||
fireEvent.dragOver(dropContainer)
|
||||
fireEvent.dragLeave(dragLayer)
|
||||
|
||||
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
|
||||
expect(dropZone.className).not.toContain('bg-components-dropzone-bg-accent')
|
||||
})
|
||||
|
||||
it('should ignore drop events without dataTransfer', () => {
|
||||
renderComponent()
|
||||
const { dropContainer } = getDropElements()
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { IBatchModalProps } from '../index'
|
||||
import type { IBatchModalProps } from './index'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import BatchModal, { ProcessStatus } from '../index'
|
||||
import BatchModal, { ProcessStatus } from './index'
|
||||
|
||||
vi.mock('@/service/annotation', () => ({
|
||||
annotationBatchImport: vi.fn(),
|
||||
@@ -15,13 +15,13 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../csv-downloader', () => ({
|
||||
vi.mock('./csv-downloader', () => ({
|
||||
default: () => <div data-testid="csv-downloader-stub" />,
|
||||
}))
|
||||
|
||||
let lastUploadedFile: File | undefined
|
||||
|
||||
vi.mock('../csv-uploader', () => ({
|
||||
vi.mock('./csv-uploader', () => ({
|
||||
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
|
||||
<div>
|
||||
<button
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import ClearAllAnnotationsConfirmModal from '../index'
|
||||
import ClearAllAnnotationsConfirmModal from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import EditItem, { EditItemType, EditTitle } from '../index'
|
||||
import EditItem, { EditItemType, EditTitle } from './index'
|
||||
|
||||
describe('EditTitle', () => {
|
||||
it('should render title content correctly', () => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import EditAnnotationModal from '../index'
|
||||
import EditAnnotationModal from './index'
|
||||
|
||||
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
|
||||
mockAddAnnotation: vi.fn(),
|
||||
@@ -51,6 +51,11 @@ describe('EditAnnotationModal', () => {
|
||||
onRemove: vi.fn(),
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
toastSuccessSpy.mockRestore()
|
||||
toastErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAddAnnotation.mockResolvedValue({
|
||||
@@ -60,11 +65,6 @@ describe('EditAnnotationModal', () => {
|
||||
mockEditAnnotation.mockResolvedValue({})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
toastSuccessSpy.mockRestore()
|
||||
toastErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Rendering tests (REQUIRED)
|
||||
describe('Rendering', () => {
|
||||
it('should render modal when isShow is true', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import EmptyElement from '../empty-element'
|
||||
import EmptyElement from './empty-element'
|
||||
|
||||
describe('EmptyElement', () => {
|
||||
it('should render the empty state copy and supporting icon', () => {
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { QueryParam } from '../filter'
|
||||
import type { QueryParam } from './filter'
|
||||
import type { AnnotationsCountResponse } from '@/models/log'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import * as useLogModule from '@/service/use-log'
|
||||
import Filter from '../filter'
|
||||
import Filter from './filter'
|
||||
|
||||
vi.mock('@/service/use-log')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItemBasic } from '../../type'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@@ -9,7 +8,7 @@ import * as React from 'react'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
|
||||
import HeaderOptions from '../index'
|
||||
import HeaderOptions from './index'
|
||||
|
||||
vi.mock('@headlessui/react', () => {
|
||||
type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void }
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItem } from '../type'
|
||||
import type { AnnotationItem } from './type'
|
||||
import type { App } from '@/types/app'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
@@ -10,16 +9,13 @@ import {
|
||||
addAnnotation,
|
||||
delAnnotation,
|
||||
delAnnotations,
|
||||
editAnnotation,
|
||||
fetchAnnotationConfig,
|
||||
fetchAnnotationList,
|
||||
queryAnnotationJobStatus,
|
||||
updateAnnotationScore,
|
||||
updateAnnotationStatus,
|
||||
} from '@/service/annotation'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Annotation from '../index'
|
||||
import { AnnotationEnableStatus, JobStatus } from '../type'
|
||||
import Annotation from './index'
|
||||
import { JobStatus } from './type'
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: any) => value,
|
||||
@@ -41,32 +37,29 @@ vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../filter', () => ({
|
||||
vi.mock('./filter', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="filter">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../empty-element', () => ({
|
||||
vi.mock('./empty-element', () => ({
|
||||
default: () => <div data-testid="empty-element" />,
|
||||
}))
|
||||
|
||||
vi.mock('../header-opts', () => ({
|
||||
vi.mock('./header-opts', () => ({
|
||||
default: (props: any) => (
|
||||
<div data-testid="header-opts">
|
||||
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
|
||||
add
|
||||
</button>
|
||||
<button data-testid="trigger-added" onClick={() => props.onAdded()}>
|
||||
added
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
let latestListProps: any
|
||||
|
||||
vi.mock('../list', () => ({
|
||||
vi.mock('./list', () => ({
|
||||
default: (props: any) => {
|
||||
latestListProps = props
|
||||
if (!props.list.length)
|
||||
@@ -81,7 +74,7 @@ vi.mock('../list', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../view-annotation-modal', () => ({
|
||||
vi.mock('./view-annotation-modal', () => ({
|
||||
default: (props: any) => {
|
||||
if (!props.isShow)
|
||||
return null
|
||||
@@ -89,40 +82,14 @@ vi.mock('../view-annotation-modal', () => ({
|
||||
<div data-testid="view-modal">
|
||||
<div>{props.item.question}</div>
|
||||
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
|
||||
<button data-testid="view-modal-save" onClick={() => props.onSave('Edited question', 'Edited answer')}>save</button>
|
||||
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({
|
||||
default: (props: any) => props.isShow
|
||||
? (
|
||||
<div data-testid="config-modal">
|
||||
<button
|
||||
data-testid="config-save"
|
||||
onClick={() => props.onSave({
|
||||
embedding_model_name: 'next-model',
|
||||
embedding_provider_name: 'next-provider',
|
||||
}, 0.7)}
|
||||
>
|
||||
save-config
|
||||
</button>
|
||||
<button data-testid="config-hide" onClick={props.onHide}>hide-config</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
|
||||
default: (props: any) => props.show
|
||||
? (
|
||||
<div data-testid="annotation-full-modal">
|
||||
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>hide-full</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ? <div data-testid="config-modal" /> : null }))
|
||||
vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null }))
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
|
||||
@@ -144,12 +111,9 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => {
|
||||
const addAnnotationMock = addAnnotation as Mock
|
||||
const delAnnotationMock = delAnnotation as Mock
|
||||
const delAnnotationsMock = delAnnotations as Mock
|
||||
const editAnnotationMock = editAnnotation as Mock
|
||||
const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock
|
||||
const fetchAnnotationListMock = fetchAnnotationList as Mock
|
||||
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
|
||||
const updateAnnotationScoreMock = updateAnnotationScore as Mock
|
||||
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
|
||||
const useProviderContextMock = useProviderContext as Mock
|
||||
|
||||
const appDetail = {
|
||||
@@ -182,9 +146,6 @@ describe('Annotation', () => {
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
|
||||
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
|
||||
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
|
||||
updateAnnotationScoreMock.mockResolvedValue(undefined)
|
||||
editAnnotationMock.mockResolvedValue(undefined)
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 0 },
|
||||
@@ -290,166 +251,4 @@ describe('Annotation', () => {
|
||||
expect(latestListProps.selectedIds).toEqual([annotation.id])
|
||||
})
|
||||
})
|
||||
|
||||
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
|
||||
useProviderContextMock.mockReturnValue({
|
||||
plan: {
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
},
|
||||
enableBilling: true,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('hide-annotation-full-modal'))
|
||||
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable annotations and refetch config after the async job completes', async () => {
|
||||
fetchAnnotationConfigMock.mockResolvedValueOnce({
|
||||
id: 'config-id',
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
}).mockResolvedValueOnce({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
fireEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
|
||||
appDetail.id,
|
||||
AnnotationEnableStatus.disable,
|
||||
expect.objectContaining({
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
}),
|
||||
0.5,
|
||||
)
|
||||
expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith(appDetail.id, AnnotationEnableStatus.disable, 'job-1')
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should save annotation config changes and update the score when the modal confirms', async () => {
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: false,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
|
||||
renderComponent()
|
||||
|
||||
const toggle = await screen.findByRole('switch')
|
||||
fireEvent.click(toggle)
|
||||
|
||||
expect(screen.getByTestId('config-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('config-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
|
||||
appDetail.id,
|
||||
AnnotationEnableStatus.enable,
|
||||
{
|
||||
embedding_model_name: 'next-model',
|
||||
embedding_provider_name: 'next-provider',
|
||||
},
|
||||
0.7,
|
||||
)
|
||||
expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7)
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.api.actionSuccess',
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
fireEvent.click(screen.getByTestId('list-view'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-modal-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, {
|
||||
question: 'Edited question',
|
||||
answer: 'Edited answer',
|
||||
})
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-modal-close'))
|
||||
expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('trigger-added'))
|
||||
|
||||
expect(fetchAnnotationListMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear selections on cancel and hide the config modal when requested', async () => {
|
||||
const annotation = createAnnotation()
|
||||
fetchAnnotationConfigMock.mockResolvedValue({
|
||||
id: 'config-id',
|
||||
enabled: true,
|
||||
embedding_model: {
|
||||
embedding_model_name: 'model',
|
||||
embedding_provider_name: 'provider',
|
||||
},
|
||||
score_threshold: 0.5,
|
||||
})
|
||||
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
|
||||
|
||||
renderComponent()
|
||||
|
||||
await screen.findByTestId('list')
|
||||
|
||||
await act(async () => {
|
||||
latestListProps.onSelectedIdsChange([annotation.id])
|
||||
})
|
||||
await act(async () => {
|
||||
latestListProps.onCancel()
|
||||
})
|
||||
|
||||
expect(latestListProps.selectedIds).toEqual([])
|
||||
|
||||
const configButton = document.querySelector('.action-btn') as HTMLButtonElement
|
||||
fireEvent.click(configButton)
|
||||
expect(await screen.findByTestId('config-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('config-hide'))
|
||||
expect(screen.queryByTestId('config-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AnnotationItem } from '../type'
|
||||
import type { AnnotationItem } from './type'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import List from '../list'
|
||||
import List from './list'
|
||||
|
||||
const mockFormatTime = vi.fn(() => 'formatted-time')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import RemoveAnnotationConfirmModal from '../index'
|
||||
import RemoveAnnotationConfirmModal from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../../type'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
import ViewAnnotationModal from '../index'
|
||||
import ViewAnnotationModal from './index'
|
||||
|
||||
const mockFormatTime = vi.fn(() => 'formatted-time')
|
||||
|
||||
@@ -17,7 +17,7 @@ vi.mock('@/service/annotation', () => ({
|
||||
fetchHitHistoryList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../edit-annotation-modal/edit-item', () => {
|
||||
vi.mock('../edit-annotation-modal/edit-item', () => {
|
||||
const EditItemType = {
|
||||
Query: 'query',
|
||||
Answer: 'answer',
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
@@ -6,11 +5,11 @@ import userEvent from '@testing-library/user-event'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import AccessControlDialog from '../access-control-dialog'
|
||||
import AccessControlItem from '../access-control-item'
|
||||
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
|
||||
import AccessControl from '../index'
|
||||
import SpecificGroupsOrMembers from '../specific-groups-or-members'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import AccessControl from './index'
|
||||
import SpecificGroupsOrMembers from './specific-groups-or-members'
|
||||
|
||||
const mockUseAppWhiteListSubjects = vi.fn()
|
||||
const mockUseSearchForWhiteListCandidates = vi.fn()
|
||||
@@ -19,9 +18,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
|
||||
isPending: false,
|
||||
mutateAsync: mockMutateAsync,
|
||||
}))
|
||||
const intersectionObserverMocks = vi.hoisted(() => ({
|
||||
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: <T,>(selector: (value: { userProfile: { email: string, id?: string, name?: string, avatar?: string, avatar_url?: string, is_password_set?: boolean } }) => T) => selector({
|
||||
@@ -109,10 +105,6 @@ const memberSubject: Subject = {
|
||||
|
||||
beforeAll(() => {
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
|
||||
intersectionObserverMocks.callback = callback
|
||||
}
|
||||
|
||||
observe = vi.fn(() => undefined)
|
||||
disconnect = vi.fn(() => undefined)
|
||||
unobserve = vi.fn(() => undefined)
|
||||
@@ -289,39 +281,6 @@ describe('AddMemberOrGroupDialog', () => {
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
|
||||
})
|
||||
|
||||
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
|
||||
const fetchNextPage = vi.fn()
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
isFetchingNextPage: true,
|
||||
fetchNextPage,
|
||||
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] },
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
render(<AddMemberOrGroupDialog />)
|
||||
|
||||
await user.click(screen.getByText('common.operation.add'))
|
||||
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
|
||||
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
|
||||
const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(groupCheckbox)
|
||||
fireEvent.click(groupCheckbox)
|
||||
|
||||
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
|
||||
fireEvent.click(memberCheckbox)
|
||||
fireEvent.click(memberCheckbox)
|
||||
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
|
||||
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
|
||||
|
||||
expect(useAccessControlStore.getState().specificGroups).toEqual([])
|
||||
expect(useAccessControlStore.getState().specificMembers).toEqual([])
|
||||
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
|
||||
expect(fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show empty state when no candidates are returned', async () => {
|
||||
mockUseSearchForWhiteListCandidates.mockReturnValue({
|
||||
isLoading: false,
|
||||
@@ -1,140 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import FeaturesWrappedAppPublisher from '../features-wrapper'
|
||||
|
||||
const mockSetFeatures = vi.fn()
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockAppPublisherProps = vi.hoisted(() => ({
|
||||
current: null as null | Record<string, any>,
|
||||
}))
|
||||
|
||||
const mockFeatures = {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] as string[] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false },
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
file: {
|
||||
image: {
|
||||
detail: 'high',
|
||||
enabled: false,
|
||||
number_limits: 3,
|
||||
transfer_methods: ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: false,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: ['local_file'],
|
||||
number_limits: 3,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher', () => ({
|
||||
default: (props: Record<string, any>) => {
|
||||
mockAppPublisherProps.current = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => props.onPublish?.({ id: 'model-1' })}>publish-through-wrapper</button>
|
||||
<button onClick={() => props.onRestore?.()}>restore-through-wrapper</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }),
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
features: mockFeatures,
|
||||
setFeatures: mockSetFeatures,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('FeaturesWrappedAppPublisher', () => {
|
||||
const publishedConfig = {
|
||||
modelConfig: {
|
||||
more_like_this: { enabled: true },
|
||||
opening_statement: 'Hello there',
|
||||
suggested_questions: ['Q1'],
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
annotation_reply: { enabled: true },
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
detail: 'low',
|
||||
number_limits: 5,
|
||||
transfer_methods: ['remote_url'],
|
||||
},
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.jpg'],
|
||||
allowed_file_upload_methods: ['remote_url'],
|
||||
number_limits: 5,
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppPublisherProps.current = null
|
||||
})
|
||||
|
||||
it('should pass current features through to onPublish', async () => {
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('publish-through-wrapper'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledWith({ id: 'model-1' }, mockFeatures)
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore published features after confirmation', async () => {
|
||||
render(
|
||||
<FeaturesWrappedAppPublisher
|
||||
publishedConfig={publishedConfig as any}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('restore-through-wrapper'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({
|
||||
moreLikeThis: { enabled: true },
|
||||
opening: {
|
||||
enabled: true,
|
||||
opening_statement: 'Hello there',
|
||||
suggested_questions: ['Q1'],
|
||||
},
|
||||
moderation: { enabled: true },
|
||||
speech2text: { enabled: true },
|
||||
text2speech: { enabled: true },
|
||||
suggested: { enabled: true },
|
||||
citation: { enabled: true },
|
||||
annotationReply: { enabled: true },
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,520 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import AppPublisher from '../index'
|
||||
|
||||
const mockOnPublish = vi.fn()
|
||||
const mockOnToggle = vi.fn()
|
||||
const mockSetAppDetail = vi.fn()
|
||||
const mockTrackEvent = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockOpenAsyncWindow = vi.fn()
|
||||
const mockFetchInstalledAppList = vi.fn()
|
||||
const mockFetchAppDetailDirect = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockConvertWorkflowType = vi.fn()
|
||||
|
||||
const sectionProps = vi.hoisted(() => ({
|
||||
summary: null as null | Record<string, any>,
|
||||
access: null as null | Record<string, any>,
|
||||
actions: null as null | Record<string, any>,
|
||||
}))
|
||||
const ahooksMocks = vi.hoisted(() => ({
|
||||
keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>,
|
||||
}))
|
||||
|
||||
let mockAppDetail: Record<string, any> | null = null
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
return {
|
||||
useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => {
|
||||
ahooksMocks.keyPressHandlers.push(handler)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
|
||||
appDetail: mockAppDetail,
|
||||
setAppDetail: mockSetAppDetail,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
webapp_auth: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'moments ago',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => mockOpenAsyncWindow,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({
|
||||
data: { result: true },
|
||||
isLoading: false,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useAppWhiteListSubjects: () => ({
|
||||
data: { groups: [], members: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useConvertWorkflowTypeMutation: () => ({
|
||||
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/overview/embedded', () => ({
|
||||
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow
|
||||
? (
|
||||
<div data-testid="embedded-modal">
|
||||
embedded modal
|
||||
<button onClick={onClose}>close-embedded-modal</button>
|
||||
</div>
|
||||
)
|
||||
: null),
|
||||
}))
|
||||
|
||||
vi.mock('../../app-access-control', () => ({
|
||||
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
|
||||
<div data-testid="access-control">
|
||||
<button onClick={() => void onConfirm()}>confirm-access-control</button>
|
||||
<button onClick={onClose}>close-access-control</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react')
|
||||
const OpenContext = ReactModule.createContext(false)
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext value={open}>
|
||||
<div>{children}</div>
|
||||
</OpenContext>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
|
||||
const open = ReactModule.use(OpenContext)
|
||||
return open ? <div>{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../sections', () => ({
|
||||
PublisherSummarySection: (props: Record<string, any>) => {
|
||||
sectionProps.summary = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
|
||||
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
|
||||
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
PublisherAccessSection: (props: Record<string, any>) => {
|
||||
sectionProps.access = props
|
||||
return <button onClick={props.onClick}>publisher-access-control</button>
|
||||
},
|
||||
PublisherActionsSection: (props: Record<string, any>) => {
|
||||
sectionProps.actions = props
|
||||
return (
|
||||
<div>
|
||||
<button onClick={props.handleEmbed}>publisher-embed</button>
|
||||
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('AppPublisher', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ahooksMocks.keyPressHandlers.length = 0
|
||||
sectionProps.summary = null
|
||||
sectionProps.access = null
|
||||
sectionProps.actions = null
|
||||
mockAppDetail = {
|
||||
id: 'app-1',
|
||||
name: 'Demo App',
|
||||
mode: AppModeEnum.CHAT,
|
||||
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
type: AppTypeEnum.WORKFLOW,
|
||||
site: {
|
||||
app_base_url: 'https://example.com',
|
||||
access_token: 'token-1',
|
||||
},
|
||||
}
|
||||
mockFetchInstalledAppList.mockResolvedValue({
|
||||
installed_apps: [{ id: 'installed-1' }],
|
||||
})
|
||||
mockFetchAppDetailDirect.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
mockConvertWorkflowType.mockResolvedValue({})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
|
||||
await resolver()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the publish popover and refetch access permission data', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onToggle={mockOnToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
expect(mockOnToggle).toHaveBeenCalledWith(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish and track the publish event', async () => {
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-publish'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
|
||||
action_mode: 'app',
|
||||
app_id: 'app-1',
|
||||
app_name: 'Demo App',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the embedded modal from the actions section', () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
|
||||
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close embedded and access control panels through child callbacks', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-embed'))
|
||||
fireEvent.click(screen.getByText('close-embedded-modal'))
|
||||
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('close-access-control'))
|
||||
expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should refresh app detail after access control confirmation', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('confirm-access-control'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the installed explore page through the async window helper', async () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
|
||||
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
|
||||
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore the trigger when the publish button is disabled', () => {
|
||||
render(
|
||||
<AppPublisher
|
||||
disabled
|
||||
publishedAt={Date.now()}
|
||||
onToggle={mockOnToggle}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish').parentElement?.parentElement as HTMLElement)
|
||||
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
expect(mockOnToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish from the keyboard shortcut and restore the popover state', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockResolvedValue(undefined)
|
||||
mockOnPublish.mockResolvedValue(undefined)
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
|
||||
const preventDefault = vi.fn()
|
||||
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
|
||||
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
onPublish={mockOnPublish}
|
||||
onRestore={onRestore}
|
||||
/>,
|
||||
)
|
||||
|
||||
ahooksMocks.keyPressHandlers[0]({ preventDefault })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(preventDefault).toHaveBeenCalled()
|
||||
expect(mockOnPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-summary-restore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onRestore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should report missing explore installations', async () => {
|
||||
mockFetchInstalledAppList.mockResolvedValueOnce({
|
||||
installed_apps: [],
|
||||
})
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report explore errors when the app cannot be opened', async () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
id: undefined,
|
||||
}
|
||||
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
|
||||
try {
|
||||
await resolver()
|
||||
}
|
||||
catch (error) {
|
||||
options.onError(error as Error)
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-open-in-explore'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('App not found')
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep access control open when app detail is unavailable during confirmation', async () => {
|
||||
mockAppDetail = null
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-access-control'))
|
||||
fireEvent.click(screen.getByText('confirm-access-control'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.getByTestId('access-control')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
|
||||
mockFetchAppDetailDirect.mockResolvedValueOnce({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
|
||||
params: { appId: 'app-1' },
|
||||
query: { target_type: AppTypeEnum.EVALUATION },
|
||||
})
|
||||
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
|
||||
expect(mockSetAppDetail).toHaveBeenCalledWith({
|
||||
id: 'app-1',
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
})
|
||||
})
|
||||
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide access and actions sections for evaluation workflow apps', () => {
|
||||
mockAppDetail = {
|
||||
...mockAppDetail,
|
||||
type: AppTypeEnum.EVALUATION,
|
||||
}
|
||||
|
||||
render(
|
||||
<AppPublisher
|
||||
publishedAt={Date.now()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publish'))
|
||||
|
||||
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
|
||||
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
|
||||
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
|
||||
targetType: AppTypeEnum.WORKFLOW,
|
||||
publishLabelKey: 'common.publishAsStandardWorkflow',
|
||||
switchLabelKey: 'common.switchToStandardWorkflow',
|
||||
tipKey: 'common.switchToStandardWorkflowTip',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import PublishWithMultipleModel from '../publish-with-multiple-model'
|
||||
|
||||
const mockUseProviderContext = vi.fn()
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
|
||||
const ReactModule = await vi.importActual<typeof import('react')>('react')
|
||||
const OpenContext = ReactModule.createContext(false)
|
||||
|
||||
return {
|
||||
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
|
||||
<OpenContext.Provider value={open}>
|
||||
<div data-testid="portal-root">{children}</div>
|
||||
</OpenContext.Provider>
|
||||
),
|
||||
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
|
||||
<div className={className} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
|
||||
const open = ReactModule.useContext(OpenContext)
|
||||
return open ? <div className={className}>{children}</div> : null
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('PublishWithMultipleModel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
textGenerationModelList: [
|
||||
{
|
||||
provider: 'openai',
|
||||
models: [
|
||||
{
|
||||
model: 'gpt-4o',
|
||||
label: {
|
||||
en_US: 'GPT-4o',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable the trigger when no valid model configuration is available', () => {
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={[
|
||||
{
|
||||
id: 'config-1',
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3',
|
||||
parameters: {},
|
||||
},
|
||||
]}
|
||||
onSelect={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'operation.applyConfig' })).toBeDisabled()
|
||||
expect(screen.queryByText('publishAs')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open matching model options and call onSelect', () => {
|
||||
const handleSelect = vi.fn()
|
||||
const modelConfig = {
|
||||
id: 'config-1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
parameters: { temperature: 0.7 },
|
||||
}
|
||||
|
||||
render(
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={[modelConfig]}
|
||||
onSelect={handleSelect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'operation.applyConfig' }))
|
||||
|
||||
expect(screen.getByText('publishAs')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('GPT-4o'))
|
||||
|
||||
expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining(modelConfig))
|
||||
})
|
||||
})
|
||||
@@ -1,308 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../publish-with-multiple-model', () => ({
|
||||
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
|
||||
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>publish-multiple-model</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../suggested-action', () => ({
|
||||
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
|
||||
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div>
|
||||
workflow-tool-configure
|
||||
<span>{String(props.disabledReason || '')}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('app-publisher sections', () => {
|
||||
it('should render restore controls for published chat apps', () => {
|
||||
const handleRestore = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '3 minutes ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={handleRestore}
|
||||
isChatApp
|
||||
multipleModelConfigs={[]}
|
||||
onWorkflowTypeSwitch={vi.fn()}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
workflowTypeSwitchDisabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.restore'))
|
||||
expect(handleRestore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the access control warning when subjects are missing', () => {
|
||||
render(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet={false}
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('publishApp.notSet')).toBeInTheDocument()
|
||||
expect(screen.getByText('publishApp.notSetDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the publish update action when the draft has not been published yet', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
onWorkflowTypeSwitch={vi.fn()}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
workflowTypeSwitchDisabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.publishUpdate')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple-model publishing', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[{ id: '1' } as any]}
|
||||
onWorkflowTypeSwitch={vi.fn()}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
workflowTypeSwitchDisabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('publish-multiple-model'))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
|
||||
})
|
||||
|
||||
it('should render the upgrade hint when the start node limit is exceeded', () => {
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
onWorkflowTypeSwitch={vi.fn()}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded
|
||||
upgradeHighlightStyle={{}}
|
||||
workflowTypeSwitchDisabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workflow type switch action and call switch handler', () => {
|
||||
const onWorkflowTypeSwitch = vi.fn()
|
||||
|
||||
render(
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={false}
|
||||
draftUpdatedAt={Date.now()}
|
||||
formatTimeFromNow={() => '1 minute ago'}
|
||||
handlePublish={vi.fn()}
|
||||
handleRestore={vi.fn()}
|
||||
isChatApp={false}
|
||||
multipleModelConfigs={[]}
|
||||
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
|
||||
publishDisabled={false}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
publishShortcut={['ctrl', '⇧', 'P']}
|
||||
startNodeLimitExceeded={false}
|
||||
upgradeHighlightStyle={{}}
|
||||
workflowTypeSwitchConfig={{
|
||||
targetType: 'evaluation',
|
||||
publishLabelKey: 'common.publishAsEvaluationWorkflow',
|
||||
switchLabelKey: 'common.switchToEvaluationWorkflow',
|
||||
tipKey: 'common.switchToEvaluationWorkflowTip',
|
||||
}}
|
||||
workflowTypeSwitchDisabled={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
|
||||
|
||||
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render loading access state and access mode labels when enabled', () => {
|
||||
const { rerender } = render(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet
|
||||
isLoading
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<PublisherAccessSection
|
||||
enabled
|
||||
isAppAccessSet
|
||||
isLoading={false}
|
||||
accessMode={AccessMode.PUBLIC}
|
||||
onClick={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('accessControlDialog.accessItems.anyone')).toBeInTheDocument()
|
||||
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
|
||||
const handleOpenInExplore = vi.fn()
|
||||
const handleEmbed = vi.fn()
|
||||
|
||||
const { rerender } = render(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'workflow-app',
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
icon: '⚙️',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#fff',
|
||||
name: 'Workflow App',
|
||||
description: 'Workflow description',
|
||||
}}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={true}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished
|
||||
workflowToolAvailable={false}
|
||||
workflowToolMessage="workflow-disabled"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
|
||||
fireEvent.click(screen.getByText('common.openInExplore'))
|
||||
expect(handleOpenInExplore).toHaveBeenCalled()
|
||||
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{
|
||||
id: 'chat-app',
|
||||
mode: AppModeEnum.CHAT,
|
||||
name: 'Chat App',
|
||||
}}
|
||||
appURL="https://example.com/app?foo=bar"
|
||||
disabledFunctionButton
|
||||
disabledFunctionTooltip="disabled"
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode={false}
|
||||
inputs={[]}
|
||||
missingStartNode
|
||||
onRefreshData={vi.fn()}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={Date.now()}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.embedIntoSite'))
|
||||
expect(handleEmbed).toHaveBeenCalled()
|
||||
expect(screen.getByText('common.accessAPIReference')).toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<PublisherActionsSection
|
||||
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
|
||||
appURL="https://example.com/app"
|
||||
disabledFunctionButton={false}
|
||||
handleEmbed={handleEmbed}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={vi.fn()}
|
||||
hasHumanInputNode={false}
|
||||
hasTriggerNode
|
||||
inputs={[]}
|
||||
missingStartNode={false}
|
||||
outputs={[]}
|
||||
published={false}
|
||||
publishedAt={undefined}
|
||||
toolPublished={false}
|
||||
workflowToolAvailable
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('common.runApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SuggestedAction from '../suggested-action'
|
||||
|
||||
describe('SuggestedAction', () => {
|
||||
it('should render an enabled external link', () => {
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs">
|
||||
Open docs
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Open docs' })
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/docs')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('should block clicks when disabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
|
||||
Disabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
|
||||
fireEvent.click(link)
|
||||
|
||||
expect(link).not.toHaveAttribute('href')
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should forward click events when enabled', () => {
|
||||
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
render(
|
||||
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
|
||||
Enabled action
|
||||
</SuggestedAction>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppMode,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from '../utils'
|
||||
|
||||
describe('app-publisher utils', () => {
|
||||
describe('getPublisherAppMode', () => {
|
||||
it('should normalize chat-like apps to chat mode', () => {
|
||||
expect(getPublisherAppMode(AppModeEnum.AGENT_CHAT)).toBe(AppModeEnum.CHAT)
|
||||
})
|
||||
|
||||
it('should keep completion mode unchanged', () => {
|
||||
expect(getPublisherAppMode(AppModeEnum.COMPLETION)).toBe(AppModeEnum.COMPLETION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublisherAppUrl', () => {
|
||||
it('should build the published app url from site info', () => {
|
||||
expect(getPublisherAppUrl({
|
||||
appBaseUrl: 'https://example.com',
|
||||
accessToken: 'token-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})).toBe(`https://example.com${basePath}/chat/token-1`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPublisherAccessConfigured', () => {
|
||||
it('should require members or groups for specific access mode', () => {
|
||||
expect(isPublisherAccessConfigured(
|
||||
{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
|
||||
{ groups: [], members: [] },
|
||||
)).toBe(false)
|
||||
})
|
||||
|
||||
it('should treat public access as configured', () => {
|
||||
expect(isPublisherAccessConfigured(
|
||||
{ access_mode: AccessMode.PUBLIC },
|
||||
{ groups: [], members: [] },
|
||||
)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisabledFunctionTooltip', () => {
|
||||
const t = ((key: string) => key) as unknown as TFunction
|
||||
|
||||
it('should prioritize the unpublished hint', () => {
|
||||
expect(getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt: undefined,
|
||||
missingStartNode: false,
|
||||
noAccessPermission: false,
|
||||
})).toBe('notPublishedYet')
|
||||
})
|
||||
|
||||
it('should return the access error when the app is published but blocked', () => {
|
||||
expect(getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt: Date.now(),
|
||||
missingStartNode: false,
|
||||
noAccessPermission: true,
|
||||
})).toBe('noAccessPermission')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import VersionInfoModal from '../version-info-modal'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('VersionInfoModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should prefill the fields from the current version info', () => {
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-1',
|
||||
marked_name: 'Release 1',
|
||||
marked_comment: 'Initial release',
|
||||
} as any}
|
||||
onClose={vi.fn()}
|
||||
onPublish={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Release 1')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Initial release')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reject overlong titles', () => {
|
||||
const handlePublish = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
|
||||
expect(handlePublish).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should publish valid values and close the modal', () => {
|
||||
const handlePublish = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-2',
|
||||
marked_name: 'Old title',
|
||||
marked_comment: 'Old notes',
|
||||
} as any}
|
||||
onClose={handleClose}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
fireEvent.change(titleInput, { target: { value: 'Release 2' } })
|
||||
fireEvent.change(notesInput, { target: { value: 'Updated notes' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({
|
||||
title: 'Release 2',
|
||||
releaseNotes: 'Updated notes',
|
||||
id: 'version-2',
|
||||
})
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should validate release note length and clear previous errors before publishing', () => {
|
||||
const handlePublish = vi.fn()
|
||||
const handleClose = vi.fn()
|
||||
|
||||
render(
|
||||
<VersionInfoModal
|
||||
isOpen
|
||||
versionInfo={{
|
||||
id: 'version-3',
|
||||
marked_name: 'Old title',
|
||||
marked_comment: 'Old notes',
|
||||
} as any}
|
||||
onClose={handleClose}
|
||||
onPublish={handlePublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const [titleInput, notesInput] = screen.getAllByRole('textbox')
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
|
||||
|
||||
fireEvent.change(titleInput, { target: { value: 'Release 3' } })
|
||||
fireEvent.change(notesInput, { target: { value: 'b'.repeat(101) } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.releaseNotesLengthLimit')
|
||||
|
||||
fireEvent.change(notesInput, { target: { value: 'Stable release notes' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
|
||||
|
||||
expect(handlePublish).toHaveBeenCalledWith({
|
||||
title: 'Release 3',
|
||||
releaseNotes: 'Stable release notes',
|
||||
id: 'version-3',
|
||||
})
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { InputVar, Variable } from '@/app/components/workflow/types'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import {
|
||||
memo,
|
||||
@@ -15,11 +15,15 @@ import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
@@ -27,22 +31,56 @@ import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppTypeEnum } from '@/types/app'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { toast } from '../../base/ui/toast'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
import {
|
||||
PublisherAccessSection,
|
||||
PublisherActionsSection,
|
||||
PublisherSummarySection,
|
||||
} from './sections'
|
||||
import {
|
||||
getDisabledFunctionTooltip,
|
||||
getPublisherAppUrl,
|
||||
isPublisherAccessConfigured,
|
||||
} from './utils'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: 'i-ri-building-line',
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: 'i-ri-lock-line',
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: 'i-ri-global-line',
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: 'i-ri-verified-badge-line',
|
||||
},
|
||||
}
|
||||
|
||||
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -70,32 +108,6 @@ export type AppPublisherProps = {
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
|
||||
|
||||
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, {
|
||||
targetType: WorkflowTypeConversionTarget
|
||||
publishLabelKey: WorkflowTypeSwitchLabelKey
|
||||
switchLabelKey: WorkflowTypeSwitchLabelKey
|
||||
tipKey: WorkflowTypeSwitchLabelKey
|
||||
}> = {
|
||||
workflow: {
|
||||
targetType: 'evaluation',
|
||||
publishLabelKey: 'common.publishAsEvaluationWorkflow',
|
||||
switchLabelKey: 'common.switchToEvaluationWorkflow',
|
||||
tipKey: 'common.switchToEvaluationWorkflowTip',
|
||||
},
|
||||
evaluation: {
|
||||
targetType: 'workflow',
|
||||
publishLabelKey: 'common.publishAsStandardWorkflow',
|
||||
switchLabelKey: 'common.switchToStandardWorkflow',
|
||||
tipKey: 'common.switchToStandardWorkflowTip',
|
||||
},
|
||||
} as const
|
||||
|
||||
const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => {
|
||||
return type === 'workflow' || type === 'evaluation'
|
||||
}
|
||||
|
||||
const AppPublisher = ({
|
||||
disabled = false,
|
||||
publishDisabled = false,
|
||||
@@ -130,34 +142,33 @@ const AppPublisher = ({
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
|
||||
|
||||
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
|
||||
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
|
||||
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type)
|
||||
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
|
||||
: undefined
|
||||
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
|
||||
const isAppAccessSet = useMemo(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
}
|
||||
return true
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const noAccessPermission = useMemo(() => Boolean(
|
||||
systemFeatures.webapp_auth.enabled
|
||||
&& appDetail
|
||||
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
|
||||
&& !userCanAccessApp?.result,
|
||||
), [systemFeatures, appDetail, userCanAccessApp])
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({
|
||||
t,
|
||||
publishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
}), [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
const disabledFunctionTooltip = useMemo(() => {
|
||||
if (!publishedAt)
|
||||
return t('notPublishedYet', { ns: 'app' })
|
||||
if (missingStartNode)
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
}, [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
@@ -225,35 +236,6 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const handleWorkflowTypeSwitch = useCallback(async () => {
|
||||
if (!appDetail?.id || !workflowTypeSwitchConfig)
|
||||
return
|
||||
|
||||
try {
|
||||
await convertWorkflowType({
|
||||
params: {
|
||||
appId: appDetail.id,
|
||||
},
|
||||
query: {
|
||||
target_type: workflowTypeSwitchConfig.targetType,
|
||||
},
|
||||
})
|
||||
|
||||
if (!publishedAt)
|
||||
await handlePublish()
|
||||
|
||||
const latestAppDetail = await fetchAppDetailDirect({
|
||||
url: '/apps',
|
||||
id: appDetail.id,
|
||||
})
|
||||
setAppDetail(latestAppDetail)
|
||||
|
||||
if (publishedAt)
|
||||
setOpen(false)
|
||||
}
|
||||
catch { }
|
||||
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published)
|
||||
@@ -262,9 +244,9 @@ const AppPublisher = ({
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
|
||||
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
|
||||
: undefined
|
||||
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
|
||||
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
|
||||
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
|
||||
const upgradeHighlightStyle = useMemo(() => ({
|
||||
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
@@ -286,7 +268,7 @@ const AppPublisher = ({
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="py-2 pr-2 pl-3"
|
||||
className="py-2 pl-3 pr-2"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('common.publish', { ns: 'workflow' })}
|
||||
@@ -295,58 +277,199 @@ const AppPublisher = ({
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-11">
|
||||
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<PublisherSummarySection
|
||||
debugWithMultipleModel={debugWithMultipleModel}
|
||||
draftUpdatedAt={draftUpdatedAt}
|
||||
formatTimeFromNow={formatTimeFromNow}
|
||||
handlePublish={handlePublish}
|
||||
handleRestore={handleRestore}
|
||||
isChatApp={isChatApp}
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
publishDisabled={publishDisabled}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
publishShortcut={PUBLISH_SHORTCUT}
|
||||
startNodeLimitExceeded={startNodeLimitExceeded}
|
||||
upgradeHighlightStyle={upgradeHighlightStyle}
|
||||
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
|
||||
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType}
|
||||
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
|
||||
/>
|
||||
{!isEvaluationWorkflowType && (
|
||||
<>
|
||||
<PublisherAccessSection
|
||||
enabled={systemFeatures.webapp_auth.enabled}
|
||||
isAppAccessSet={isAppAccessSet}
|
||||
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
|
||||
accessMode={appDetail?.access_mode}
|
||||
onClick={() => setShowAppAccessControl(true)}
|
||||
/>
|
||||
<PublisherActionsSection
|
||||
appDetail={appDetail}
|
||||
appURL={appURL}
|
||||
disabledFunctionButton={disabledFunctionButton}
|
||||
disabledFunctionTooltip={disabledFunctionTooltip}
|
||||
handleEmbed={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
handleOpenInExplore={handleOpenInExplore}
|
||||
handlePublish={handlePublish}
|
||||
hasHumanInputNode={hasHumanInputNode}
|
||||
hasTriggerNode={hasTriggerNode}
|
||||
inputs={inputs}
|
||||
missingStartNode={missingStartNode}
|
||||
onRefreshData={onRefreshData}
|
||||
outputs={outputs}
|
||||
published={published}
|
||||
publishedAt={publishedAt}
|
||||
toolPublished={toolPublished}
|
||||
workflowToolAvailable={workflowToolAvailable}
|
||||
workflowToolMessage={workflowToolMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
|
||||
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center text-text-secondary system-sm-medium">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
// textGenerationModelList={textGenerationModelList}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{
|
||||
published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
{showStartNodeLimitHint && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm font-semibold leading-5 text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
|
||||
? <div className="py-2"><Loading /></div>
|
||||
: (
|
||||
<>
|
||||
<Divider className="my-0" />
|
||||
{systemFeatures.webapp_auth.enabled && (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center">
|
||||
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
|
||||
onClick={() => {
|
||||
setShowAppAccessControl(true)
|
||||
}}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<AccessModeDisplay mode={appDetail?.access_mode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
// Hide run/batch run app buttons when there is a trigger node.
|
||||
!hasTriggerNode && (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className="h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.openInExplore', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<Tooltip triggerClassName="flex" disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })} asChild={false}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
outputs={outputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
<EmbeddedModal
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import type { AppPublisherProps } from './index'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/app/components/base/ui/tooltip'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import { ACCESS_MODE_MAP } from './utils'
|
||||
|
||||
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
|
||||
|
||||
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
|
||||
| 'draftUpdatedAt'
|
||||
| 'multipleModelConfigs'
|
||||
| 'publishDisabled'
|
||||
| 'publishedAt'
|
||||
| 'startNodeLimitExceeded'> & {
|
||||
formatTimeFromNow: (value: number) => string
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
handleRestore: () => Promise<void>
|
||||
isChatApp: boolean
|
||||
onWorkflowTypeSwitch: () => Promise<void>
|
||||
published: boolean
|
||||
publishShortcut: string[]
|
||||
upgradeHighlightStyle: CSSProperties
|
||||
workflowTypeSwitchConfig?: {
|
||||
targetType: WorkflowTypeConversionTarget
|
||||
publishLabelKey: WorkflowTypeSwitchLabelKey
|
||||
switchLabelKey: WorkflowTypeSwitchLabelKey
|
||||
tipKey: WorkflowTypeSwitchLabelKey
|
||||
}
|
||||
workflowTypeSwitchDisabled: boolean
|
||||
}
|
||||
|
||||
type AccessSectionProps = {
|
||||
enabled: boolean
|
||||
isAppAccessSet: boolean
|
||||
isLoading: boolean
|
||||
accessMode?: keyof typeof ACCESS_MODE_MAP
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
|
||||
| 'hasTriggerNode'
|
||||
| 'inputs'
|
||||
| 'missingStartNode'
|
||||
| 'onRefreshData'
|
||||
| 'toolPublished'
|
||||
| 'outputs'
|
||||
| 'publishedAt'
|
||||
| 'workflowToolAvailable'> & {
|
||||
appDetail: {
|
||||
id?: string
|
||||
icon?: string
|
||||
icon_type?: string | null
|
||||
icon_background?: string | null
|
||||
description?: string
|
||||
mode?: AppModeEnum
|
||||
name?: string
|
||||
} | null | undefined
|
||||
appURL: string
|
||||
disabledFunctionButton: boolean
|
||||
disabledFunctionTooltip?: string
|
||||
handleEmbed: () => void
|
||||
handleOpenInExplore: () => void
|
||||
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
|
||||
published: boolean
|
||||
workflowToolMessage?: string
|
||||
}
|
||||
|
||||
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
|
||||
<div className="grow truncate">
|
||||
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherSummarySection = ({
|
||||
debugWithMultipleModel = false,
|
||||
draftUpdatedAt,
|
||||
formatTimeFromNow,
|
||||
handlePublish,
|
||||
handleRestore,
|
||||
isChatApp,
|
||||
multipleModelConfigs = [],
|
||||
onWorkflowTypeSwitch,
|
||||
publishDisabled = false,
|
||||
published,
|
||||
publishedAt,
|
||||
publishShortcut,
|
||||
startNodeLimitExceeded = false,
|
||||
upgradeHighlightStyle,
|
||||
workflowTypeSwitchConfig,
|
||||
workflowTypeSwitchDisabled,
|
||||
}: SummarySectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
|
||||
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.publishedAt', { ns: 'workflow' })}
|
||||
{' '}
|
||||
{formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && (
|
||||
<Button
|
||||
variant="secondary-accent"
|
||||
size="small"
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('common.restore', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center system-sm-medium text-text-secondary">
|
||||
{t('common.autoSaved', { ns: 'workflow' })}
|
||||
{' '}
|
||||
·
|
||||
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{published
|
||||
? t('common.published', { ns: 'workflow' })
|
||||
: (
|
||||
<div className="flex gap-1">
|
||||
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
|
||||
<ShortcutsName keys={publishShortcut} bgColor="white" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
{workflowTypeSwitchConfig && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => void onWorkflowTypeSwitch()}
|
||||
disabled={workflowTypeSwitchDisabled}
|
||||
>
|
||||
<span className="px-0.5">
|
||||
{t(
|
||||
publishedAt
|
||||
? workflowTypeSwitchConfig.switchLabelKey
|
||||
: workflowTypeSwitchConfig.publishLabelKey,
|
||||
{ ns: 'workflow' },
|
||||
)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
|
||||
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent
|
||||
placement="top"
|
||||
popupClassName="w-[180px]"
|
||||
>
|
||||
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
)}
|
||||
{startNodeLimitExceeded && (
|
||||
<div className="mt-3 flex flex-col items-stretch">
|
||||
<p
|
||||
className="text-sm leading-5 font-semibold text-transparent"
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
|
||||
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-4 text-text-secondary">
|
||||
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherAccessSection = ({
|
||||
enabled,
|
||||
isAppAccessSet,
|
||||
isLoading,
|
||||
accessMode,
|
||||
onClick,
|
||||
}: AccessSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isLoading)
|
||||
return <div className="py-2"><Loading /></div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="my-0" />
|
||||
{enabled && (
|
||||
<div className="p-4 pt-3">
|
||||
<div className="flex h-6 items-center">
|
||||
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
|
||||
<AccessModeDisplay mode={accessMode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className="mt-1 system-xs-regular text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ActionTooltip = ({
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean
|
||||
tooltip?: ReactNode
|
||||
children: ReactNode
|
||||
}) => {
|
||||
if (!disabled || !tooltip)
|
||||
return <>{children}</>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="flex">{children}</div>} />
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export const PublisherActionsSection = ({
|
||||
appDetail,
|
||||
appURL,
|
||||
disabledFunctionButton,
|
||||
disabledFunctionTooltip,
|
||||
handleEmbed,
|
||||
handleOpenInExplore,
|
||||
handlePublish,
|
||||
hasHumanInputNode = false,
|
||||
hasTriggerNode = false,
|
||||
inputs,
|
||||
missingStartNode = false,
|
||||
onRefreshData,
|
||||
outputs,
|
||||
published,
|
||||
publishedAt,
|
||||
toolPublished,
|
||||
workflowToolAvailable = true,
|
||||
workflowToolMessage,
|
||||
}: ActionsSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (hasTriggerNode)
|
||||
return null
|
||||
|
||||
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.runApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.batchRunApp', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={handleEmbed}
|
||||
disabled={!publishedAt}
|
||||
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
|
||||
>
|
||||
{t('common.embedIntoSite', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<span className="i-ri-planet-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.openInExplore', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
<ActionTooltip
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
tooltip={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })}
|
||||
>
|
||||
<SuggestedAction
|
||||
className="flex-1"
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link="./develop"
|
||||
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
|
||||
>
|
||||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</ActionTooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id ?? ''}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name ?? ''}
|
||||
description={appDetail?.description ?? ''}
|
||||
inputs={inputs}
|
||||
outputs={outputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { I18nKeysByPrefix } from '@/types/i18n'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
type AccessSubjectsLike = {
|
||||
groups?: unknown[]
|
||||
members?: unknown[]
|
||||
} | null | undefined
|
||||
|
||||
type AppDetailLike = {
|
||||
access_mode?: AccessMode
|
||||
mode?: AppModeEnum
|
||||
}
|
||||
|
||||
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
|
||||
|
||||
export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: 'i-ri-building-line',
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: 'i-ri-lock-line',
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: 'i-ri-global-line',
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: 'i-ri-verified-badge-line',
|
||||
},
|
||||
}
|
||||
|
||||
export const getPublisherAppMode = (mode?: AppModeEnum) => {
|
||||
if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW)
|
||||
return AppModeEnum.CHAT
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export const getPublisherAppUrl = ({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
mode,
|
||||
}: {
|
||||
appBaseUrl: string
|
||||
accessToken: string
|
||||
mode?: AppModeEnum
|
||||
}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}`
|
||||
|
||||
export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => {
|
||||
if (!appDetail || !appAccessSubjects)
|
||||
return true
|
||||
|
||||
if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
return true
|
||||
|
||||
return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length)
|
||||
}
|
||||
|
||||
export const getDisabledFunctionTooltip = ({
|
||||
t,
|
||||
publishedAt,
|
||||
missingStartNode,
|
||||
noAccessPermission,
|
||||
}: {
|
||||
t: TFunction
|
||||
publishedAt?: number
|
||||
missingStartNode: boolean
|
||||
noAccessPermission: boolean
|
||||
}) => {
|
||||
if (!publishedAt)
|
||||
return t('notPublishedYet', { ns: 'app' })
|
||||
if (missingStartNode)
|
||||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ConfigurationViewModel } from '../hooks/use-configuration'
|
||||
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
|
||||
import type ConfigContext from '@/context/debug-configuration'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
import ConfigurationView from '../configuration-view'
|
||||
|
||||
vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({
|
||||
default: () => <div data-testid="app-publisher" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config', () => ({
|
||||
default: () => <div data-testid="config-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/debug', () => ({
|
||||
default: () => <div data-testid="debug-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
|
||||
default: () => <div data-testid="agent-setting-button" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
|
||||
default: () => <div data-testid="select-dataset" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
|
||||
default: () => <div data-testid="history-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
|
||||
default: () => <div data-testid="feature-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
|
||||
default: () => <div data-testid="plugin-dependency" />,
|
||||
}))
|
||||
|
||||
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
|
||||
appId: 'app-1',
|
||||
isAPIKeySet: true,
|
||||
isTrailFinished: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelModeType: ModelModeType.chat,
|
||||
promptMode: 'simple' as never,
|
||||
setPromptMode: vi.fn(),
|
||||
isAdvancedMode: false,
|
||||
isAgent: false,
|
||||
isFunctionCall: false,
|
||||
isOpenAI: false,
|
||||
collectionList: [],
|
||||
canReturnToSimpleMode: false,
|
||||
setCanReturnToSimpleMode: vi.fn(),
|
||||
chatPromptConfig: { prompt: [] } as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
currentAdvancedPrompt: [],
|
||||
setCurrentAdvancedPrompt: vi.fn(),
|
||||
showHistoryModal: vi.fn(),
|
||||
conversationHistoriesRole: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
setConversationHistoriesRole: vi.fn(),
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
conversationId: '',
|
||||
setConversationId: vi.fn(),
|
||||
introduction: '',
|
||||
setIntroduction: vi.fn(),
|
||||
suggestedQuestions: [],
|
||||
setSuggestedQuestions: vi.fn(),
|
||||
controlClearChatMessage: 0,
|
||||
setControlClearChatMessage: vi.fn(),
|
||||
prevPromptConfig: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
setPrevPromptConfig: vi.fn(),
|
||||
moreLikeThisConfig: { enabled: false },
|
||||
setMoreLikeThisConfig: vi.fn(),
|
||||
suggestedQuestionsAfterAnswerConfig: { enabled: false },
|
||||
setSuggestedQuestionsAfterAnswerConfig: vi.fn(),
|
||||
speechToTextConfig: { enabled: false },
|
||||
setSpeechToTextConfig: vi.fn(),
|
||||
textToSpeechConfig: { enabled: false, voice: '', language: '' },
|
||||
setTextToSpeechConfig: vi.fn(),
|
||||
citationConfig: { enabled: false },
|
||||
setCitationConfig: vi.fn(),
|
||||
annotationConfig: {
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: 0.5,
|
||||
embedding_model: {
|
||||
embedding_model_name: '',
|
||||
embedding_provider_name: '',
|
||||
},
|
||||
},
|
||||
setAnnotationConfig: vi.fn(),
|
||||
moderationConfig: { enabled: false },
|
||||
setModerationConfig: vi.fn(),
|
||||
externalDataToolsConfig: [],
|
||||
setExternalDataToolsConfig: vi.fn(),
|
||||
formattingChanged: false,
|
||||
setFormattingChanged: vi.fn(),
|
||||
inputs: {},
|
||||
setInputs: vi.fn(),
|
||||
query: '',
|
||||
setQuery: vi.fn(),
|
||||
completionParams: {},
|
||||
setCompletionParams: vi.fn(),
|
||||
modelConfig: {
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: '',
|
||||
prompt_variables: [],
|
||||
},
|
||||
chat_prompt_config: null,
|
||||
completion_prompt_config: null,
|
||||
opening_statement: '',
|
||||
more_like_this: null,
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: null,
|
||||
speech_to_text: null,
|
||||
text_to_speech: null,
|
||||
file_upload: null,
|
||||
retriever_resource: null,
|
||||
sensitive_word_avoidance: null,
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 1,
|
||||
file_size_limit: 1,
|
||||
image_file_size_limit: 1,
|
||||
video_file_size_limit: 1,
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
dataSets: [],
|
||||
agentConfig: {
|
||||
enabled: false,
|
||||
strategy: 'react',
|
||||
max_iteration: 1,
|
||||
tools: [],
|
||||
},
|
||||
} as never,
|
||||
setModelConfig: vi.fn(),
|
||||
dataSets: [],
|
||||
setDataSets: vi.fn(),
|
||||
showSelectDataSet: vi.fn(),
|
||||
datasetConfigs: {
|
||||
retrieval_model: 'multiple',
|
||||
reranking_model: {
|
||||
reranking_provider_name: '',
|
||||
reranking_model_name: '',
|
||||
},
|
||||
top_k: 3,
|
||||
score_threshold_enabled: false,
|
||||
score_threshold: 0.5,
|
||||
datasets: { datasets: [] },
|
||||
} as never,
|
||||
datasetConfigsRef: { current: {} as never },
|
||||
setDatasetConfigs: vi.fn(),
|
||||
hasSetContextVar: false,
|
||||
isShowVisionConfig: false,
|
||||
visionConfig: {
|
||||
enabled: false,
|
||||
number_limits: 1,
|
||||
detail: 'low',
|
||||
transfer_methods: ['local_file'],
|
||||
} as never,
|
||||
setVisionConfig: vi.fn(),
|
||||
isAllowVideoUpload: false,
|
||||
isShowDocumentConfig: false,
|
||||
isShowAudioConfig: false,
|
||||
rerankSettingModalOpen: false,
|
||||
setRerankSettingModalOpen: vi.fn(),
|
||||
})
|
||||
|
||||
const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): ConfigurationViewModel => ({
|
||||
appPublisherProps: {
|
||||
publishDisabled: false,
|
||||
publishedAt: 0,
|
||||
debugWithMultipleModel: false,
|
||||
multipleModelConfigs: [],
|
||||
onPublish: vi.fn(),
|
||||
publishedConfig: {
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
completionParams: {},
|
||||
},
|
||||
resetAppConfig: vi.fn(),
|
||||
} as ComponentProps<typeof AppPublisher>,
|
||||
contextValue: createContextValue(),
|
||||
featuresData: {
|
||||
moreLikeThis: { enabled: false },
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
moderation: { enabled: false },
|
||||
speech2text: { enabled: false },
|
||||
text2speech: { enabled: false, voice: '', language: '' },
|
||||
file: { enabled: false, image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] } } as never,
|
||||
suggested: { enabled: false },
|
||||
citation: { enabled: false },
|
||||
annotationReply: { enabled: false },
|
||||
},
|
||||
isAgent: false,
|
||||
isAdvancedMode: false,
|
||||
isMobile: false,
|
||||
isShowDebugPanel: false,
|
||||
isShowHistoryModal: false,
|
||||
isShowSelectDataSet: false,
|
||||
modelConfig: createContextValue().modelConfig,
|
||||
multipleModelConfigs: [],
|
||||
onAutoAddPromptVariable: vi.fn(),
|
||||
onAgentSettingChange: vi.fn(),
|
||||
onCloseFeaturePanel: vi.fn(),
|
||||
onCloseHistoryModal: vi.fn(),
|
||||
onCloseSelectDataSet: vi.fn(),
|
||||
onCompletionParamsChange: vi.fn(),
|
||||
onConfirmUseGPT4: vi.fn(),
|
||||
onEnableMultipleModelDebug: vi.fn(),
|
||||
onFeaturesChange: vi.fn(),
|
||||
onHideDebugPanel: vi.fn(),
|
||||
onModelChange: vi.fn(),
|
||||
onMultipleModelConfigsChange: vi.fn(),
|
||||
onOpenAccountSettings: vi.fn(),
|
||||
onOpenDebugPanel: vi.fn(),
|
||||
onSaveHistory: vi.fn(),
|
||||
onSelectDataSets: vi.fn(),
|
||||
promptVariables: [],
|
||||
selectedIds: [],
|
||||
showAppConfigureFeaturesModal: false,
|
||||
showLoading: false,
|
||||
showUseGPT4Confirm: false,
|
||||
setShowUseGPT4Confirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('ConfigurationView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render a loading state before configuration data is ready', () => {
|
||||
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the mobile debug panel from the header button', () => {
|
||||
const onOpenDebugPanel = vi.fn()
|
||||
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i }))
|
||||
|
||||
expect(onOpenDebugPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close the GPT-4 confirmation dialog when cancel is clicked', () => {
|
||||
const setShowUseGPT4Confirm = vi.fn()
|
||||
render(<ConfigurationView {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i }))
|
||||
|
||||
expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useConfiguration } from '../hooks/use-configuration'
|
||||
import Configuration from '../index'
|
||||
|
||||
const mockView = vi.fn((_: unknown) => <div data-testid="configuration-view" />)
|
||||
|
||||
vi.mock('../configuration-view', () => ({
|
||||
default: (props: unknown) => mockView(props),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks/use-configuration', () => ({
|
||||
useConfiguration: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('Configuration entry', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should pass the hook view model into ConfigurationView', () => {
|
||||
const viewModel = {
|
||||
showLoading: true,
|
||||
}
|
||||
vi.mocked(useConfiguration).mockReturnValue(viewModel as never)
|
||||
|
||||
render(<Configuration />)
|
||||
|
||||
expect(useConfiguration).toHaveBeenCalledTimes(1)
|
||||
expect(mockView).toHaveBeenCalledWith(viewModel)
|
||||
})
|
||||
})
|
||||
@@ -1,226 +0,0 @@
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
|
||||
import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils'
|
||||
|
||||
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
|
||||
provider: 'openai',
|
||||
model_id: 'gpt-4o',
|
||||
mode: ModelModeType.chat,
|
||||
configs: {
|
||||
prompt_template: 'Hello',
|
||||
prompt_variables: [],
|
||||
},
|
||||
chat_prompt_config: {
|
||||
prompt: [],
|
||||
} as ModelConfig['chat_prompt_config'],
|
||||
completion_prompt_config: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as ModelConfig['completion_prompt_config'],
|
||||
opening_statement: '',
|
||||
more_like_this: { enabled: false },
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
text_to_speech: { enabled: false, voice: '', language: '' },
|
||||
file_upload: null,
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
annotation_reply: null,
|
||||
external_data_tools: [],
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 1,
|
||||
file_size_limit: 1,
|
||||
image_file_size_limit: 1,
|
||||
video_file_size_limit: 1,
|
||||
workflow_file_upload_limit: 1,
|
||||
},
|
||||
dataSets: [],
|
||||
agentConfig: {
|
||||
enabled: false,
|
||||
strategy: 'react',
|
||||
max_iteration: 1,
|
||||
tools: [],
|
||||
} as ModelConfig['agentConfig'],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('configuration utils', () => {
|
||||
describe('withCollectionIconBasePath', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should prefix relative collection icons with the base path', () => {
|
||||
const result = withCollectionIconBasePath([
|
||||
{ id: 'tool-1', icon: '/icons/tool.svg' },
|
||||
{ id: 'tool-2', icon: '/console/icons/prefixed.svg' },
|
||||
] as never, '/console')
|
||||
|
||||
expect(result[0].icon).toBe('/console/icons/tool.svg')
|
||||
expect(result[1].icon).toBe('/console/icons/prefixed.svg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildConfigurationFeaturesData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should derive feature toggles and upload fallbacks from model config', () => {
|
||||
const result = buildConfigurationFeaturesData(createModelConfig({
|
||||
opening_statement: 'Welcome',
|
||||
suggested_questions: ['How are you?'],
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
image: {
|
||||
enabled: true,
|
||||
detail: Resolution.low,
|
||||
number_limits: 2,
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
},
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 2,
|
||||
},
|
||||
}), undefined)
|
||||
|
||||
expect(result.opening).toEqual({
|
||||
enabled: true,
|
||||
opening_statement: 'Welcome',
|
||||
suggested_questions: ['How are you?'],
|
||||
})
|
||||
expect(result.file).toBeDefined()
|
||||
expect(result.file!.enabled).toBe(true)
|
||||
expect(result.file!.image!.detail).toBe(Resolution.low)
|
||||
expect(result.file!.allowed_file_extensions).toEqual(['.png'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfigurationPublishingState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should block publish when advanced completion mode is missing required blocks', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'Answer' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: false,
|
||||
query: false,
|
||||
},
|
||||
hasSetContextVar: false,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.CHAT,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(false)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
|
||||
it('should require a context variable only for completion apps with selected datasets', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'Completion prompt' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: false,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: false,
|
||||
hasSelectedDataSets: true,
|
||||
isAdvancedMode: false,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'Prompt',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(false)
|
||||
expect(result.cannotPublish).toBe(false)
|
||||
expect(result.contextVarEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it('should treat advanced completion chat prompts as empty when every segment is blank', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [{ text: '' }, { text: '' }],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: 'ignored' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: true,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: true,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.chat,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(true)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
|
||||
it('should treat advanced completion text prompts as empty when the completion prompt is missing', () => {
|
||||
const result = getConfigurationPublishingState({
|
||||
chatPromptConfig: {
|
||||
prompt: [{ text: 'ignored' }],
|
||||
} as never,
|
||||
completionPromptConfig: {
|
||||
prompt: { text: '' },
|
||||
conversation_histories_role: {
|
||||
user_prefix: 'user',
|
||||
assistant_prefix: 'assistant',
|
||||
},
|
||||
} as never,
|
||||
hasSetBlockStatus: {
|
||||
context: true,
|
||||
history: true,
|
||||
query: true,
|
||||
},
|
||||
hasSetContextVar: true,
|
||||
hasSelectedDataSets: false,
|
||||
isAdvancedMode: true,
|
||||
mode: AppModeEnum.COMPLETION,
|
||||
modelModeType: ModelModeType.completion,
|
||||
promptTemplate: 'ignored',
|
||||
})
|
||||
|
||||
expect(result.promptEmpty).toBe(true)
|
||||
expect(result.cannotPublish).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import FeaturePanel from '../index'
|
||||
import FeaturePanel from './index'
|
||||
|
||||
describe('FeaturePanel', () => {
|
||||
// Rendering behavior for standard layout.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import GroupName from '../index'
|
||||
import GroupName from './index'
|
||||
|
||||
describe('GroupName', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OperationBtn from '../index'
|
||||
import OperationBtn from './index'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiAddLine: (props: { className?: string }) => (
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VarHighlight, { varHighlightHTML } from '../index'
|
||||
import VarHighlight, { varHighlightHTML } from './index'
|
||||
|
||||
describe('VarHighlight', () => {
|
||||
beforeEach(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import CannotQueryDataset from '../cannot-query-dataset'
|
||||
import CannotQueryDataset from './cannot-query-dataset'
|
||||
|
||||
describe('CannotQueryDataset WarningMask', () => {
|
||||
it('should render dataset warning copy and action button', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import FormattingChanged from '../formatting-changed'
|
||||
import FormattingChanged from './formatting-changed'
|
||||
|
||||
describe('FormattingChanged WarningMask', () => {
|
||||
it('should display translation text and both actions', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import HasNotSetAPI from '../has-not-set-api'
|
||||
import HasNotSetAPI from './has-not-set-api'
|
||||
|
||||
describe('HasNotSetAPI', () => {
|
||||
it('should render the empty state copy', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import WarningMask from '../index'
|
||||
import WarningMask from './index'
|
||||
|
||||
describe('WarningMask', () => {
|
||||
// Rendering of title, description, and footer content
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user