Compare commits

..

8 Commits

Author SHA1 Message Date
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
477 changed files with 10904 additions and 73499 deletions

View File

@@ -29,8 +29,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6]
shardTotal: [6]
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
defaults:
run:
shell: bash

View File

@@ -3,7 +3,7 @@ import time
from collections.abc import Mapping
from datetime import datetime
from functools import cached_property
from typing import Any, TypedDict, cast
from typing import Any, cast
from uuid import uuid4
import sqlalchemy as sa
@@ -24,44 +24,6 @@ from .model import Account
from .types import EnumText, LongText, StringUUID
class WorkflowTriggerLogDict(TypedDict):
id: str
tenant_id: str
app_id: str
workflow_id: str
workflow_run_id: str | None
root_node_id: str | None
trigger_metadata: Any
trigger_type: str
trigger_data: Any
inputs: Any
outputs: Any
status: str
error: str | None
queue_name: str
celery_task_id: str | None
retry_count: int
elapsed_time: float | None
total_tokens: int | None
created_by_role: str
created_by: str
created_at: str | None
triggered_at: str | None
finished_at: str | None
class WorkflowSchedulePlanDict(TypedDict):
id: str
app_id: str
node_id: str
tenant_id: str
cron_expression: str
timezone: str
next_run_at: str | None
created_at: str
updated_at: str
class TriggerSubscription(TypeBase):
"""
Trigger provider model for managing credentials
@@ -288,7 +250,7 @@ class WorkflowTriggerLog(TypeBase):
created_by_role = CreatorUserRole(self.created_by_role)
return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None
def to_dict(self) -> WorkflowTriggerLogDict:
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for API responses"""
return {
"id": self.id,
@@ -519,7 +481,7 @@ class WorkflowSchedulePlan(TypeBase):
DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), init=False
)
def to_dict(self) -> WorkflowSchedulePlanDict:
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary representation"""
return {
"id": self.id,

View File

@@ -3,7 +3,7 @@ import logging
from collections.abc import Generator, Mapping, Sequence
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Optional, TypedDict, Union, cast
from typing import TYPE_CHECKING, Any, Optional, Union, cast
from uuid import uuid4
import sqlalchemy as sa
@@ -60,22 +60,6 @@ from .types import EnumText, LongText, StringUUID
logger = logging.getLogger(__name__)
class WorkflowContentDict(TypedDict):
graph: Mapping[str, Any]
features: dict[str, Any]
environment_variables: list[dict[str, Any]]
conversation_variables: list[dict[str, Any]]
rag_pipeline_variables: list[dict[str, Any]]
class WorkflowRunSummaryDict(TypedDict):
id: str
status: str
triggered_from: str
elapsed_time: float
total_tokens: int
class WorkflowType(StrEnum):
"""
Workflow Type Enum
@@ -518,14 +502,14 @@ class Workflow(Base): # bug
)
self._environment_variables = environment_variables_json
def to_dict(self, *, include_secret: bool = False) -> WorkflowContentDict:
def to_dict(self, *, include_secret: bool = False) -> Mapping[str, Any]:
environment_variables = list(self.environment_variables)
environment_variables = [
v if not isinstance(v, SecretVariable) or include_secret else v.model_copy(update={"value": ""})
for v in environment_variables
]
result: WorkflowContentDict = {
result = {
"graph": self.graph_dict,
"features": self.features_dict,
"environment_variables": [var.model_dump(mode="json") for var in environment_variables],
@@ -1247,7 +1231,7 @@ class WorkflowArchiveLog(TypeBase):
)
@property
def workflow_run_summary(self) -> WorkflowRunSummaryDict:
def workflow_run_summary(self) -> dict[str, Any]:
return {
"id": self.workflow_run_id,
"status": self.run_status,

View File

@@ -18,7 +18,7 @@ from extensions.ext_database import db
from models.account import Account
from models.enums import CreatorUserRole, WorkflowTriggerStatus
from models.model import App, EndUser
from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
from models.trigger import WorkflowTriggerLog
from models.workflow import Workflow
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
@@ -224,9 +224,7 @@ class AsyncWorkflowService:
return cls.trigger_workflow_async(session, user, trigger_data)
@classmethod
def get_trigger_log(
cls, workflow_trigger_log_id: str, tenant_id: str | None = None
) -> WorkflowTriggerLogDict | None:
def get_trigger_log(cls, workflow_trigger_log_id: str, tenant_id: str | None = None) -> dict[str, Any] | None:
"""
Get trigger log by ID
@@ -249,7 +247,7 @@ class AsyncWorkflowService:
@classmethod
def get_recent_logs(
cls, tenant_id: str, app_id: str, hours: int = 24, limit: int = 100, offset: int = 0
) -> list[WorkflowTriggerLogDict]:
) -> list[dict[str, Any]]:
"""
Get recent trigger logs
@@ -274,7 +272,7 @@ class AsyncWorkflowService:
@classmethod
def get_failed_logs_for_retry(
cls, tenant_id: str, max_retry_count: int = 3, limit: int = 100
) -> list[WorkflowTriggerLogDict]:
) -> list[dict[str, Any]]:
"""
Get failed logs eligible for retry

View File

@@ -295,7 +295,24 @@ describe('Pricing Modal Flow', () => {
})
})
// ─── 6. Pricing URL ─────────────────────────────────────────────────────
// ─── 6. Close Handling ───────────────────────────────────────────────────
describe('Close handling', () => {
it('should call onCancel when pressing ESC key', () => {
render(<Pricing onCancel={onCancel} />)
// ahooks useKeyPress listens on document for keydown events
document.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
bubbles: true,
}))
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
// ─── 7. Pricing URL ─────────────────────────────────────────────────────
describe('Pricing page URL', () => {
it('should render pricing link with correct URL', () => {
render(<Pricing onCancel={onCancel} />)

View File

@@ -8,8 +8,6 @@
import { cleanup, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
let mockTheme = 'light'
vi.mock('#i18n', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -21,16 +19,16 @@ vi.mock('@/context/i18n', () => ({
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
default: () => ({ theme: 'light' }),
}))
vi.mock('@/i18n-config', () => ({
renderI18nObject: (obj: Record<string, string>, locale: string) => obj[locale] || obj.en_US || '',
}))
vi.mock('@/types/app', async () => {
return vi.importActual<typeof import('@/types/app')>('@/types/app')
})
vi.mock('@/types/app', () => ({
Theme: { dark: 'dark', light: 'light' },
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: unknown[]) => args.filter(a => typeof a === 'string' && a).join(' '),
@@ -102,7 +100,6 @@ type CardPayload = Parameters<typeof Card>[0]['payload']
describe('Plugin Card Rendering Integration', () => {
beforeEach(() => {
cleanup()
mockTheme = 'light'
})
const makePayload = (overrides = {}) => ({
@@ -197,7 +194,9 @@ describe('Plugin Card Rendering Integration', () => {
})
it('uses dark icon when theme is dark and icon_dark is provided', () => {
mockTheme = 'dark'
vi.doMock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'dark' }),
}))
const payload = makePayload({
icon: 'https://example.com/icon-light.png',
@@ -205,7 +204,7 @@ describe('Plugin Card Rendering Integration', () => {
})
render(<Card payload={payload} />)
expect(screen.getByTestId('card-icon')).toHaveTextContent('https://example.com/icon-dark.png')
expect(screen.getByTestId('card-icon')).toBeInTheDocument()
})
it('shows loading placeholder when isLoading is true', () => {

View File

@@ -0,0 +1,11 @@
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

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
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,
},
]
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,
})
return navConfig
}, [t])

View File

@@ -0,0 +1,11 @@
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

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
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 [
{
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,
]
}
return baseNavigation

View File

@@ -6,7 +6,7 @@ import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="evaluation" />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} section="orchestrate" />
}
export default Page

View File

@@ -0,0 +1,21 @@
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')
})
})

View File

@@ -0,0 +1,11 @@
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

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -160,7 +160,7 @@ const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="mb-3 text-text-primary title-2xl-semi-bold">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('avatar.deleteTitle', { ns: 'common' })}</div>
<p className="mb-8 text-text-secondary">{t('avatar.deleteDescription', { ns: 'common' })}</p>
<div className="flex w-full items-center justify-center gap-2">

View File

@@ -209,14 +209,14 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
</div>
{step === STEP.start && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.title', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-warning body-md-medium">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">
<div className="body-md-medium text-text-warning">{t('account.changeEmail.authTip', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content1"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
@@ -241,19 +241,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
)}
{step === STEP.verifyOrigin && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content2"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
@@ -278,25 +278,25 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
<span onClick={sendCodeToOriginEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.newEmail', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">{t('account.changeEmail.content3', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">{t('account.changeEmail.content3', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.emailLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.emailPlaceholder', { ns: 'common' })}
@@ -305,10 +305,10 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.existingEmail', { ns: 'common' })}</div>
)}
{unAvailableEmail && (
<div className="mt-1 py-0.5 text-text-destructive body-xs-regular">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
<div className="body-xs-regular mt-1 py-0.5 text-text-destructive">{t('account.changeEmail.unAvailableEmail', { ns: 'common' })}</div>
)}
</div>
<div className="mt-3 space-y-2">
@@ -331,19 +331,19 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
)}
{step === STEP.verifyNew && (
<>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('account.changeEmail.verifyNew', { ns: 'common' })}</div>
<div className="space-y-0.5 pb-2 pt-1">
<div className="text-text-secondary body-md-regular">
<div className="body-md-regular text-text-secondary">
<Trans
i18nKey="account.changeEmail.content4"
ns="common"
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className="pt-3">
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('account.changeEmail.codeLabel', { ns: 'common' })}</div>
<Input
className="!w-full"
placeholder={t('account.changeEmail.codePlaceholder', { ns: 'common' })}
@@ -368,13 +368,13 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<span>{t('account.changeEmail.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('account.changeEmail.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className="cursor-pointer text-text-accent-secondary system-xs-medium">{t('account.changeEmail.resend', { ns: 'common' })}</span>
<span onClick={sendCodeToNewEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('account.changeEmail.resend', { ns: 'common' })}</span>
)}
</div>
</>

View File

@@ -145,7 +145,7 @@ export default function AccountPage() {
imageUrl={icon_url}
/>
</div>
<div className="mt-[3px] text-text-secondary system-sm-medium">{item.name}</div>
<div className="system-sm-medium mt-[3px] text-text-secondary">{item.name}</div>
</div>
)
}
@@ -153,12 +153,12 @@ export default function AccountPage() {
return (
<>
<div className="pb-3 pt-2">
<h4 className="text-text-primary title-2xl-semi-bold">{t('account.myAccount', { ns: 'common' })}</h4>
<h4 className="title-2xl-semi-bold text-text-primary">{t('account.myAccount', { ns: 'common' })}</h4>
</div>
<div className="mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6">
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size="3xl" />
<div className="ml-4">
<p className="text-text-primary system-xl-semibold">
<p className="system-xl-semibold text-text-primary">
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
@@ -167,16 +167,16 @@ export default function AccountPage() {
</PremiumBadge>
)}
</p>
<p className="text-text-tertiary system-xs-regular">{userProfile.email}</p>
<p className="system-xs-regular text-text-tertiary">{userProfile.email}</p>
</div>
</div>
<div className="mb-8">
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.name}</span>
</div>
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={handleEditName}>
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={handleEditName}>
{t('operation.edit', { ns: 'common' })}
</div>
</div>
@@ -184,11 +184,11 @@ export default function AccountPage() {
<div className="mb-8">
<div className={titleClassName}>{t('account.email', { ns: 'common' })}</div>
<div className="mt-2 flex w-full items-center justify-between gap-2">
<div className="flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled system-sm-regular">
<div className="system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled ">
<span className="pl-1">{userProfile.email}</span>
</div>
{systemFeatures.enable_change_email && (
<div className="cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text system-sm-medium" onClick={() => setShowUpdateEmail(true)}>
<div className="system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text" onClick={() => setShowUpdateEmail(true)}>
{t('operation.change', { ns: 'common' })}
</div>
)}
@@ -198,8 +198,8 @@ export default function AccountPage() {
systemFeatures.enable_email_password_login && (
<div className="mb-8 flex justify-between gap-2">
<div>
<div className="mb-1 text-text-secondary system-sm-semibold">{t('account.password', { ns: 'common' })}</div>
<div className="mb-2 text-text-tertiary body-xs-regular">{t('account.passwordTip', { ns: 'common' })}</div>
<div className="system-sm-semibold mb-1 text-text-secondary">{t('account.password', { ns: 'common' })}</div>
<div className="body-xs-regular mb-2 text-text-tertiary">{t('account.passwordTip', { ns: 'common' })}</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</Button>
</div>
@@ -226,7 +226,7 @@ export default function AccountPage() {
onClose={() => setEditNameModalVisible(false)}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{t('account.editName', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-6 text-text-primary">{t('account.editName', { ns: 'common' })}</div>
<div className={titleClassName}>{t('account.name', { ns: 'common' })}</div>
<Input
className="mt-2"
@@ -256,7 +256,7 @@ export default function AccountPage() {
}}
className="!w-[420px] !p-6"
>
<div className="mb-6 text-text-primary title-2xl-semi-bold">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
<div className="title-2xl-semi-bold mb-6 text-text-primary">{userProfile.is_password_set ? t('account.resetPassword', { ns: 'common' }) : t('account.setPassword', { ns: 'common' })}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('account.currentPassword', { ns: 'common' })}</div>
@@ -279,7 +279,7 @@ export default function AccountPage() {
</div>
</>
)}
<div className="mt-8 text-text-secondary system-sm-semibold">
<div className="system-sm-semibold mt-8 text-text-secondary">
{userProfile.is_password_set ? t('account.newPassword', { ns: 'common' }) : t('account.password', { ns: 'common' })}
</div>
<div className="relative mt-2">
@@ -298,7 +298,7 @@ export default function AccountPage() {
</Button>
</div>
</div>
<div className="mt-8 text-text-secondary system-sm-semibold">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="system-sm-semibold mt-8 text-text-secondary">{t('account.confirmPassword', { ns: 'common' })}</div>
<div className="relative mt-2">
<Input
type={showConfirmPassword ? 'text' : 'password'}

View File

@@ -165,6 +165,21 @@ 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', () => {

View File

@@ -27,12 +27,16 @@ export 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,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ 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)
})
})
})

View File

@@ -14,13 +14,15 @@ 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 = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
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')}>
@@ -70,13 +77,32 @@ 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={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')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,53 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import { cn } from '@/utils/classnames'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
return (
<div className={cn('flex flex-col', expand ? '' : 'p-1')}>
<div className="flex flex-col gap-2 p-2">
<div className="flex items-start gap-3">
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && (
<div className="min-w-0 flex-1">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
{snippet.status && (
<div className="pt-1">
<Badge>{snippet.status}</Badge>
</div>
)}
</div>
)}
</div>
{expand && snippet.description && (
<p className="line-clamp-3 text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -94,7 +94,7 @@ const CSVUploader: FC<Props> = ({
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg system-sm-regular', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
<div className={cn('system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-tertiary">

View File

@@ -2,19 +2,25 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import HasNotSetAPI from './has-not-set-api'
describe('HasNotSetAPI', () => {
it('should render the empty state copy', () => {
render(<HasNotSetAPI onSetting={vi.fn()} />)
describe('HasNotSetAPI WarningMask', () => {
it('should show default title when trial not finished', () => {
render(<HasNotSetAPI isTrailFinished={false} onSetting={vi.fn()} />)
expect(screen.getByText('appDebug.noModelProviderConfigured')).toBeInTheDocument()
expect(screen.getByText('appDebug.noModelProviderConfiguredTip')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
})
it('should call onSetting when manage models button is clicked', () => {
const onSetting = vi.fn()
render(<HasNotSetAPI onSetting={onSetting} />)
it('should show trail finished title when flag is true', () => {
render(<HasNotSetAPI isTrailFinished onSetting={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.manageModels' }))
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
it('should call onSetting when primary button clicked', () => {
const onSetting = vi.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,38 +2,38 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import WarningMask from '.'
export type IHasNotSetAPIProps = {
isTrailFinished: boolean
onSetting: () => void
}
const icon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 6.00001L14 2.00001M14 2.00001H9.99999M14 2.00001L8 8M6.66667 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.71569 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.07989 14 5.2 14H10.8C11.9201 14 12.4802 14 12.908 13.782C13.2843 13.5903 13.5903 13.2843 13.782 12.908C14 12.4802 14 11.9201 14 10.8V9.33333" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({
isTrailFinished,
onSetting,
}) => {
const { t } = useTranslation()
return (
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelProviderConfigured', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelProviderConfiguredTip', { ns: 'appDebug' })}</div>
</div>
<button
type="button"
className="flex w-fit items-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]"
onClick={onSetting}
>
<span className="text-components-button-secondary-accent-text system-sm-medium">{t('manageModels', { ns: 'appDebug' })}</span>
<span className="i-ri-arrow-right-line h-4 w-4 text-components-button-secondary-accent-text" />
</button>
</div>
</div>
<WarningMask
title={isTrailFinished ? t('notSetAPIKey.trailFinished', { ns: 'appDebug' }) : t('notSetAPIKey.title', { ns: 'appDebug' })}
description={t('notSetAPIKey.description', { ns: 'appDebug' })}
footer={(
<Button variant="primary" className="flex space-x-2" onClick={onSetting}>
<span>{t('notSetAPIKey.settingBtn', { ns: 'appDebug' })}</span>
{icon}
</Button>
)}
/>
)
}
export default React.memo(HasNotSetAPI)

View File

@@ -178,7 +178,7 @@ const Prompt: FC<ISimplePromptInput> = ({
{!noTitle && (
<div className="flex h-11 items-center justify-between pl-3 pr-2.5">
<div className="flex items-center space-x-1">
<div className="h2 text-text-secondary system-sm-semibold-uppercase">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
<div className="h2 system-sm-semibold-uppercase text-text-secondary">{mode !== AppModeEnum.COMPLETION ? t('chatSubTitle', { ns: 'appDebug' }) : t('completionSubTitle', { ns: 'appDebug' })}</div>
{!readonly && (
<Tooltip
popupContent={(

View File

@@ -96,7 +96,7 @@ const Editor: FC<Props> = ({
)}
</div>
</div>
<div className={cn(editorHeight, 'min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
<div className={cn(editorHeight, ' min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
<PromptEditor
className={editorHeight}
value={value}

View File

@@ -3,10 +3,8 @@ import type { FormValue } from '@/app/components/header/account-setting/model-pr
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import type { GenRes } from '@/service/debug'
import type { AppModeEnum, CompletionParams, Model, ModelModeType } from '@/types/app'
import {
useBoolean,
useSessionStorageState,
} from 'ahooks'
import { useSessionStorageState } from 'ahooks'
import useBoolean from 'ahooks/lib/useBoolean'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -226,7 +224,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
</div>
<div>
<div className="text-[0px]">
<div className="mb-1.5 text-text-secondary system-sm-semibold-uppercase">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<div className="system-sm-semibold-uppercase mb-1.5 text-text-secondary">{t('codegen.instruction', { ns: 'appDebug' })}</div>
<InstructionEditor
editorKey={editorKey}
value={instruction}
@@ -250,7 +248,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
disabled={isLoading}
>
<Generator className="h-4 w-4" />
<span className="text-xs font-semibold">{t('codegen.generate', { ns: 'appDebug' })}</span>
<span className="text-xs font-semibold ">{t('codegen.generate', { ns: 'appDebug' })}</span>
</Button>
</div>
</div>

View File

@@ -210,7 +210,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
<div className="overflow-y-auto border-b border-divider-regular p-6 pb-[68px] pt-5">
<div className={cn(rowClass, 'items-center')}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.name', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.name', { ns: 'datasetSettings' })}</div>
</div>
<Input
value={localeCurrentDataset.name}
@@ -221,7 +221,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.desc', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.desc', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<Textarea
@@ -234,7 +234,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.permissions', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.permissions', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<PermissionSelector
@@ -250,7 +250,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
{!!(currentDataset && currentDataset.indexing_technique) && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.indexMethod', { ns: 'datasetSettings' })}</div>
</div>
<div className="grow">
<IndexMethod
@@ -267,7 +267,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
{indexMethod === IndexingType.QUALIFIED && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className="text-text-secondary system-sm-semibold">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
<div className="system-sm-semibold text-text-secondary">{t('form.embeddingModel', { ns: 'datasetSettings' })}</div>
</div>
<div className="w-full">
<div className="h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60">

View File

@@ -1,25 +1,13 @@
import type { ReactNode } from 'react'
import type { ModelAndParameter } from '../types'
import type {
FormValue,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelParameterTrigger from './model-parameter-trigger'
const mockUseDebugConfigurationContext = vi.fn()
const mockUseDebugWithMultipleModelContext = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseCredentialPanelState = vi.fn()
const mockUseLanguage = vi.fn()
type RenderTriggerProps = {
open: boolean
@@ -47,12 +35,8 @@ vi.mock('./context', () => ({
useDebugWithMultipleModelContext: () => mockUseDebugWithMultipleModelContext(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => mockUseCredentialPanelState(),
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => mockUseLanguage(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
@@ -100,41 +84,6 @@ const createModelAndParameter = (overrides: Partial<ModelAndParameter> = {}): Mo
...overrides,
})
const createModelProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: 'Help' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.com' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: 'Model', zh_Hans: 'Model' },
placeholder: { en_US: 'Select model', zh_Hans: 'Select model' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'Primary Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Primary Key' }],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
...overrides,
})
const renderComponent = (props: Partial<{ modelAndParameter: ModelAndParameter }> = {}) => {
const defaultProps = {
modelAndParameter: createModelAndParameter(),
@@ -157,19 +106,8 @@ describe('ModelParameterTrigger', () => {
onMultipleModelConfigsChange: vi.fn(),
onDebugWithMultipleModelChange: vi.fn(),
})
mockUseProviderContext.mockReturnValue(createMockProviderContextValue({
modelProviders: [createModelProvider()],
}))
mockUseCredentialPanelState.mockReturnValue({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary Key',
credits: 10,
})
mockUseLanguage.mockReturnValue('en_US')
})
describe('rendering', () => {
@@ -373,66 +311,23 @@ describe('ModelParameterTrigger', () => {
expect(screen.getByTestId('model-parameter-modal')).toBeInTheDocument()
})
it('should render "Select Model" text when no provider or model is configured', () => {
renderComponent({
modelAndParameter: createModelAndParameter({
provider: '',
model: '',
}),
})
it('should render "Select Model" text when no provider/model', () => {
renderComponent()
// When currentProvider and currentModel are null, shows "Select Model"
expect(screen.getByText('common.modelProvider.selectModel')).toBeInTheDocument()
})
})
describe('language context', () => {
it('should use language from useLanguage hook', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
it('should render configured model id and incompatible tooltip when model is missing from the provider list', () => {
renderComponent()
expect(screen.getByText('gpt-3.5-turbo')).toBeInTheDocument()
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.incompatibleTip')
})
it('should render configure required tooltip for no-configure status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: false,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
render(<>{triggerContent}</>)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.configureRequired')
})
it('should render disabled tooltip for disabled status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: false,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.disabled },
})
unmount()
render(<>{triggerContent}</>)
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.modelProvider.selector.disabled')
})
it('should apply expanded and warning styles when the trigger is open for a non-active status', () => {
const { unmount } = renderComponent()
const triggerContent = capturedModalProps?.renderTrigger({
open: true,
currentProvider: { provider: 'openai' },
currentModel: { model: 'gpt-3.5-turbo', status: ModelStatusEnum.noConfigure },
})
unmount()
const { container } = render(<>{triggerContent}</>)
expect(container.firstChild).toHaveClass('bg-state-base-hover')
expect(container.firstChild).toHaveClass('!bg-[#FFFAEB]')
// The language is used for MODEL_STATUS_TEXT tooltip
// We verify the hook is called
expect(mockUseLanguage).toHaveBeenCalled()
})
})

View File

@@ -1,20 +1,22 @@
import type { FC } from 'react'
import type { ModelAndParameter } from '../types'
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RiArrowDownSLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
import Tooltip from '@/app/components/base/tooltip'
import {
DERIVED_MODEL_STATUS_BADGE_I18N,
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
deriveModelStatus,
} from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
MODEL_STATUS_TEXT,
ModelStatusEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useProviderContext } from '@/context/provider-context'
import { useDebugWithMultipleModelContext } from './context'
type ModelParameterTriggerProps = {
@@ -32,10 +34,8 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange,
} = useDebugWithMultipleModelContext()
const { modelProviders } = useProviderContext()
const language = useLanguage()
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const providerMeta = modelProviders.find(provider => provider.provider === modelAndParameter.provider)
const credentialState = useCredentialPanelState(providerMeta)
const handleSelectModel = ({ modelId, provider }: { modelId: string, provider: string }) => {
const newModelConfigs = [...multipleModelConfigs]
@@ -69,77 +69,55 @@ const ModelParameterTrigger: FC<ModelParameterTriggerProps> = ({
open,
currentProvider,
currentModel,
}) => {
const status = deriveModelStatus(
modelAndParameter.model,
modelAndParameter.provider,
providerMeta,
currentModel ?? undefined,
credentialState,
)
const iconProvider = currentProvider || providerMeta
const statusLabelKey = DERIVED_MODEL_STATUS_BADGE_I18N[status]
const statusTooltipKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status]
const isEmpty = status === 'empty'
const isActive = status === 'active'
return (
<div
className={`
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
${open && 'bg-state-base-hover'}
${!isEmpty && !isActive && '!bg-[#FFFAEB]'}
`}
>
{
iconProvider && !isEmpty && (
<ModelIcon
className="mr-1 !h-4 !w-4"
provider={iconProvider}
modelName={currentModel?.model || modelAndParameter.model}
/>
)
}
{
(!iconProvider || isEmpty) && (
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
<span className="i-custom-vender-line-shapes-cube-outline h-4 w-4 text-text-accent" />
</div>
)
}
{
currentModel && (
<ModelName
className="mr-0.5 text-text-secondary"
modelItem={currentModel}
/>
)
}
{
!currentModel && !isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-secondary">
{modelAndParameter.model}
</div>
)
}
{
isEmpty && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t('modelProvider.selectModel', { ns: 'common' })}
</div>
)
}
<span className={`i-ri-arrow-down-s-line h-3 w-3 ${isEmpty ? 'text-text-accent' : 'text-text-tertiary'}`} />
{
!isEmpty && !isActive && statusLabelKey && (
<Tooltip popupContent={t((statusTooltipKey || statusLabelKey) as 'modelProvider.selector.incompatible', { ns: 'common' })}>
<span className="i-custom-vender-line-alertsAndFeedback-alert-triangle h-4 w-4 text-[#F79009]" />
</Tooltip>
)
}
</div>
)
}}
}) => (
<div
className={`
flex h-8 max-w-[200px] cursor-pointer items-center rounded-lg px-2
${open && 'bg-state-base-hover'}
${currentModel && currentModel.status !== ModelStatusEnum.active && '!bg-[#FFFAEB]'}
`}
>
{
currentProvider && (
<ModelIcon
className="mr-1 !h-4 !w-4"
provider={currentProvider}
modelName={currentModel?.model}
/>
)
}
{
!currentProvider && (
<div className="mr-1 flex h-4 w-4 items-center justify-center rounded">
<CubeOutline className="h-4 w-4 text-text-accent" />
</div>
)
}
{
currentModel && (
<ModelName
className="mr-0.5 text-text-secondary"
modelItem={currentModel}
/>
)
}
{
!currentModel && (
<div className="mr-0.5 truncate text-[13px] font-medium text-text-accent">
{t('modelProvider.selectModel', { ns: 'common' })}
</div>
)
}
<RiArrowDownSLine className={`h-3 w-3 ${(currentModel && currentProvider) ? 'text-text-tertiary' : 'text-text-accent'}`} />
{
currentModel && currentModel.status !== ModelStatusEnum.active && (
<Tooltip popupContent={MODEL_STATUS_TEXT[currentModel.status][language]}>
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</Tooltip>
)
}
</div>
)}
/>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
import TooltipPlus from '@/app/components/base/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
@@ -394,7 +394,7 @@ const Debug: FC<IDebug> = ({
<>
<div className="shrink-0">
<div className="flex items-center justify-between px-4 pb-2 pt-3">
<div className="text-text-primary system-xl-semibold">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="system-xl-semibold text-text-primary">{t('inputs.title', { ns: 'appDebug' })}</div>
<div className="flex items-center">
{
debugWithMultipleModel
@@ -505,26 +505,6 @@ const Debug: FC<IDebug> = ({
{
!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={ref}>
{/* No model provider configured */}
{(!modelConfig.provider || !isAPIKeySet) && (
<HasNotSetAPIKEY onSetting={onSetting} />
)}
{/* No model selected */}
{modelConfig.provider && isAPIKeySet && !modelConfig.model_id && (
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelSelected', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelSelectedTip', { ns: 'appDebug' })}</div>
</div>
</div>
</div>
)}
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
@@ -559,7 +539,7 @@ const Debug: FC<IDebug> = ({
{!completionRes && !isResponding && (
<div className="flex grow flex-col items-center justify-center gap-2">
<RiSparklingFill className="h-12 w-12 text-text-empty-state-icon" />
<div className="text-text-quaternary system-sm-regular">{t('noResult', { ns: 'appDebug' })}</div>
<div className="system-sm-regular text-text-quaternary">{t('noResult', { ns: 'appDebug' })}</div>
</div>
)}
</>
@@ -590,6 +570,7 @@ const Debug: FC<IDebug> = ({
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</>
)
}

View File

@@ -966,10 +966,10 @@ const Configuration: FC = () => {
<div className="bg-default-subtle absolute left-0 top-0 h-14 w-full">
<div className="flex h-14 items-center justify-between px-6">
<div className="flex items-center">
<div className="text-text-primary system-xl-semibold">{t('orchestrate', { ns: 'appDebug' })}</div>
<div className="system-xl-semibold text-text-primary">{t('orchestrate', { ns: 'appDebug' })}</div>
<div className="flex h-[14px] items-center space-x-1 text-xs">
{isAdvancedMode && (
<div className="ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary system-xs-medium-uppercase">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
<div className="system-xs-medium-uppercase ml-1 flex h-5 items-center rounded-md border border-components-button-secondary-border px-1.5 uppercase text-text-tertiary">{t('promptMode.advanced', { ns: 'appDebug' })}</div>
)}
</div>
</div>
@@ -1030,8 +1030,8 @@ const Configuration: FC = () => {
<Config />
</div>
{!isMobile && (
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto" style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg">
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg ">
<Debug
isAPIKeySet={isAPIKeySet}
onSetting={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })}

View File

@@ -217,7 +217,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<AppIcon
size="large"
onClick={() => { setShowEmojiPicker(true) }}
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border"
className="!h-9 !w-9 cursor-pointer rounded-lg border-[0.5px] border-components-panel-border "
icon={localeData.icon}
background={localeData.icon_background}
/>

View File

@@ -232,7 +232,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
isShow={show}
onClose={noop}
>
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary title-2xl-semi-bold">
<div className="title-2xl-semi-bold flex items-center justify-between pb-3 pl-6 pr-5 pt-6 text-text-primary">
{t('importFromDSL', { ns: 'app' })}
<div
className="flex h-8 w-8 cursor-pointer items-center"
@@ -241,7 +241,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
<div className="system-md-semibold flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary">
{
tabs.map(tab => (
<div
@@ -275,7 +275,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
{
currentTab === CreateFromDSLModalTab.FROM_URL && (
<div>
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
<div className="system-md-semibold mb-1 text-text-secondary">DSL URL</div>
<Input
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
value={dslUrlValue}
@@ -309,8 +309,8 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />

View File

@@ -121,7 +121,7 @@ const Uploader: FC<Props> = ({
</div>
)}
{file && (
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', 'hover:bg-components-panel-on-panel-item-bg-hover')}>
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
<div className="flex items-center justify-center p-3">
<YamlIcon className="h-6 w-6 shrink-0" />
</div>

View File

@@ -96,7 +96,7 @@ const statusTdRender = (statusCount: StatusCount) => {
if (statusCount.paused > 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="yellow" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
@@ -104,7 +104,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else if (statusCount.partial_success + statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Success</span>
</div>
@@ -112,7 +112,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else if (statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="green" />
<span className="text-util-colors-green-green-600">Partial Success</span>
</div>
@@ -120,7 +120,7 @@ const statusTdRender = (statusCount: StatusCount) => {
}
else {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
<Indicator color="red" />
<span className="text-util-colors-red-red-600">
{statusCount.failed}
@@ -562,9 +562,9 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
{/* Panel Header */}
<div className="flex shrink-0 items-center gap-2 rounded-t-xl bg-components-panel-bg pb-2 pl-4 pr-3 pt-3">
<div className="shrink-0">
<div className="mb-0.5 text-text-primary system-xs-semibold-uppercase">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
<div className="system-xs-semibold-uppercase mb-0.5 text-text-primary">{isChatMode ? t('detail.conversationId', { ns: 'appLog' }) : t('detail.time', { ns: 'appLog' })}</div>
{isChatMode && (
<div className="flex items-center text-text-secondary system-2xs-regular-uppercase">
<div className="system-2xs-regular-uppercase flex items-center text-text-secondary">
<Tooltip
popupContent={detail.id}
>
@@ -574,7 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
)}
{!isChatMode && (
<div className="text-text-secondary system-2xs-regular-uppercase">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
<div className="system-2xs-regular-uppercase text-text-secondary">{formatTime(detail.created_at, t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
)}
</div>
<div className="flex grow flex-wrap items-center justify-end gap-y-1">
@@ -600,7 +600,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
? (
<div className="px-6 py-4">
<div className="flex h-[18px] items-center space-x-3">
<div className="text-text-tertiary system-xs-semibold-uppercase">{t('table.header.output', { ns: 'appLog' })}</div>
<div className="system-xs-semibold-uppercase text-text-tertiary">{t('table.header.output', { ns: 'appLog' })}</div>
<div
className="h-px grow"
style={{
@@ -692,7 +692,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
{hasMore && (
<div className="py-3 text-center">
<div className="text-text-tertiary system-xs-regular">
<div className="system-xs-regular text-text-tertiary">
{t('detail.loading', { ns: 'appLog' })}
...
</div>
@@ -950,7 +950,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
)}
popupClassName={(isHighlight && !isChatMode) ? '' : '!hidden'}
>
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'overflow-hidden text-ellipsis whitespace-nowrap system-sm-regular')}>
<div className={cn(isEmptyStyle ? 'text-text-quaternary' : 'text-text-secondary', !isHighlight ? '' : 'bg-orange-100', 'system-sm-regular overflow-hidden text-ellipsis whitespace-nowrap')}>
{value || '-'}
</div>
</Tooltip>
@@ -963,7 +963,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
return (
<div className="relative mt-2 grow overflow-x-auto">
<table className={cn('w-full min-w-[440px] border-collapse border-0')}>
<thead className="text-text-tertiary system-xs-medium-uppercase">
<thead className="system-xs-medium-uppercase text-text-tertiary">
<tr>
<td className="w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1"></td>
<td className="whitespace-nowrap bg-background-section-burn py-1.5 pl-3">{isChatMode ? t('table.header.summary', { ns: 'appLog' }) : t('table.header.input', { ns: 'appLog' })}</td>
@@ -976,7 +976,7 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
<td className="whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3">{t('table.header.time', { ns: 'appLog' })}</td>
</tr>
</thead>
<tbody className="text-text-secondary system-sm-regular">
<tbody className="system-sm-regular text-text-secondary">
{logs.data.map((log: any) => {
const endUser = log.from_end_user_session_id || log.from_account_name
const leftValue = get(log, isChatMode ? 'name' : 'message.inputs.query') || (!isChatMode ? (get(log, 'message.query') || get(log, 'message.inputs.default_input')) : '') || ''

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, screen } from '@testing-library/react'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -6,19 +6,15 @@ import { AppModeEnum } from '@/types/app'
import List from '../list'
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
}))
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
}),
}))
@@ -36,6 +32,7 @@ const mockQueryState = {
keywords: '',
isCreatedByMe: false,
}
vi.mock('../hooks/use-apps-query-state', () => ({
default: () => ({
query: mockQueryState,
@@ -45,6 +42,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
let mockOnDSLFileDropped: ((file: File) => void) | null = null
let mockDragging = false
vi.mock('../hooks/use-dsl-drag-drop', () => ({
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
mockOnDSLFileDropped = onDSLFileDropped
@@ -59,6 +57,7 @@ const mockServiceState = {
error: null as Error | null,
hasNextPage: false,
isLoading: false,
isFetching: false,
isFetchingNextPage: false,
}
@@ -100,6 +99,7 @@ vi.mock('@/service/use-apps', () => ({
useInfiniteAppList: () => ({
data: defaultAppData,
isLoading: mockServiceState.isLoading,
isFetching: mockServiceState.isFetching,
isFetchingNextPage: mockServiceState.isFetchingNextPage,
fetchNextPage: mockFetchNextPage,
hasNextPage: mockServiceState.hasNextPage,
@@ -133,13 +133,21 @@ vi.mock('next/dynamic', () => ({
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
}
}
if (fnString.includes('create-from-dsl-modal')) {
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
if (!show)
return null
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
return React.createElement(
'div',
{ 'data-testid': 'create-dsl-modal' },
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
)
}
}
return () => null
},
}))
@@ -188,9 +196,8 @@ beforeAll(() => {
} as unknown as typeof IntersectionObserver
})
// Render helper wrapping with shared nuqs testing helper.
const renderList = (searchParams = '') => {
return renderWithNuqs(<List />, { searchParams })
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
return renderWithNuqs(<List {...props} />, { searchParams })
}
describe('List', () => {
@@ -202,11 +209,13 @@ describe('List', () => {
})
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
mockDragging = false
mockOnDSLFileDropped = null
mockServiceState.error = null
mockServiceState.hasNextPage = false
mockServiceState.isLoading = false
mockServiceState.isFetching = false
mockServiceState.isFetchingNextPage = false
mockQueryState.tagIDs = []
mockQueryState.keywords = ''
@@ -215,372 +224,94 @@ describe('List', () => {
localStorage.clear()
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render tab slider with all app types', () => {
describe('Apps Mode', () => {
it('should render the apps route switch, dropdown filters, and app cards', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should render search input', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should render tag filter', () => {
renderList()
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
it('should render created by me checkbox', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
it('should render app cards when apps exist', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should render new app card for editors', () => {
renderList()
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
})
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('should render drop DSL hint for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should update URL when workflow tab is clicked', async () => {
it('should update the category query when selecting an app type from the dropdown', async () => {
const { onUrlUpdate } = renderList()
fireEvent.click(screen.getByText('app.types.workflow'))
fireEvent.click(screen.getByText('app.studio.filters.types'))
fireEvent.click(await screen.findByText('app.types.workflow'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
})
it('should update URL when all tab is clicked', async () => {
const { onUrlUpdate } = renderList('?category=workflow')
it('should keep the creators dropdown visual-only and not update app query state', async () => {
renderList()
fireEvent.click(screen.getByText('app.types.all'))
fireEvent.click(screen.getByText('app.studio.filters.creators'))
fireEvent.click(await screen.findByText('Evan'))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
// nuqs removes the default value ('all') from URL params
expect(lastCall.searchParams.has('category')).toBe(false)
expect(mockSetQuery).not.toHaveBeenCalled()
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
})
it('should render and close the DSL import modal when a file is dropped', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
})
describe('Search Functionality', () => {
it('should render search input field', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
describe('Snippets Mode', () => {
it('should render the snippets create card and fake snippet card', () => {
renderList({ pageType: 'snippets' })
expect(screen.getByText('snippet.create')).toBeInTheDocument()
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1')
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
})
it('should handle search input change', () => {
renderList()
it('should filter local snippets by the search input and show the snippet empty state', () => {
renderList({ pageType: 'snippets' })
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test search' } })
fireEvent.change(input, { target: { value: 'missing snippet' } })
expect(mockSetQuery).toHaveBeenCalled()
expect(screen.queryByText('Tone Rewriter')).not.toBeInTheDocument()
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
})
it('should handle search clear button click', () => {
mockQueryState.keywords = 'existing search'
it('should not render app-only controls in snippets mode', () => {
renderList({ pageType: 'snippets' })
renderList()
const clearButton = document.querySelector('.group')
expect(clearButton).toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Tag Filter', () => {
it('should render tag filter component', () => {
renderList()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
})
})
describe('Created By Me Filter', () => {
it('should render checkbox with correct label', () => {
renderList()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
})
it('should handle checkbox change', () => {
renderList()
it('should reserve the infinite-scroll anchor without fetching more pages', () => {
renderList({ pageType: 'snippets' })
const checkbox = screen.getByTestId('checkbox-undefined')
fireEvent.click(checkbox)
expect(mockSetQuery).toHaveBeenCalled()
})
})
describe('Non-Editor User', () => {
it('should not render new app card for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
})
it('should not render drop DSL hint for non-editors', () => {
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
renderList()
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
})
})
describe('Dataset Operator Behavior', () => {
it('should not trigger redirect at component level for dataset operators', () => {
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
renderList()
expect(mockReplace).not.toHaveBeenCalled()
})
})
describe('Local Storage Refresh', () => {
it('should call refetch when refresh key is set in localStorage', () => {
localStorage.setItem('needRefreshAppList', '1')
renderList()
expect(mockRefetch).toHaveBeenCalled()
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
})
})
describe('Edge Cases', () => {
it('should handle multiple renders without issues', () => {
const { rerender } = renderWithNuqs(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
rerender(<List />)
expect(screen.getByText('app.types.all')).toBeInTheDocument()
})
it('should render app cards correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
it('should render with all filter options visible', () => {
renderList()
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
})
})
describe('Dragging State', () => {
it('should show drop hint when DSL feature is enabled for editors', () => {
renderList()
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
})
it('should render dragging state overlay when dragging', () => {
mockDragging = true
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
describe('App Type Tabs', () => {
it('should render all app type tabs', () => {
renderList()
expect(screen.getByText('app.types.all')).toBeInTheDocument()
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
})
it('should update URL for each app type tab click', async () => {
const { onUrlUpdate } = renderList()
const appTypeTexts = [
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
]
for (const { mode, text } of appTypeTexts) {
onUrlUpdate.mockClear()
fireEvent.click(screen.getByText(text))
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(lastCall.searchParams.get('category')).toBe(mode)
}
})
})
describe('App List Display', () => {
it('should display all app cards from data', () => {
renderList()
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
})
it('should display app names correctly', () => {
renderList()
expect(screen.getByText('Test App 1')).toBeInTheDocument()
expect(screen.getByText('Test App 2')).toBeInTheDocument()
})
})
describe('Footer Visibility', () => {
it('should render footer when branding is disabled', () => {
renderList()
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
})
describe('DSL File Drop', () => {
it('should handle DSL file drop and show modal', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
})
it('should close DSL modal when onClose is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
})
it('should close DSL modal and refetch when onSuccess is called', () => {
renderList()
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
act(() => {
if (mockOnDSLFileDropped)
mockOnDSLFileDropped(mockFile)
})
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('success-dsl-modal'))
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
})
})
describe('Infinite Scroll', () => {
it('should call fetchNextPage when intersection observer triggers', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).toHaveBeenCalled()
})
it('should not call fetchNextPage when not intersecting', () => {
mockServiceState.hasNextPage = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: false } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('should not call fetchNextPage when loading', () => {
mockServiceState.hasNextPage = true
mockServiceState.isLoading = true
renderList()
if (intersectionCallback) {
act(() => {
intersectionCallback!(
[{ isIntersecting: true } as IntersectionObserverEntry],
{} as IntersectionObserver,
)
})
}
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
describe('Error State', () => {
it('should handle error state in useEffect', () => {
mockServiceState.error = new Error('Test error')
const { container } = renderList()
expect(container).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,15 @@
import { parseAsStringLiteral } from 'nuqs'
import { AppModes } from '@/types/app'
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
export const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
}
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })

View File

@@ -0,0 +1,71 @@
'use client'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { isAppListCategory } from './app-type-filter-shared'
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
type AppTypeFilterProps = {
activeTab: import('./app-type-filter-shared').AppListCategory
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
}
const AppTypeFilter = ({
activeTab,
onChange,
}: AppTypeFilterProps) => {
const { t } = useTranslation()
const options = useMemo(() => ([
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
]), [t])
const activeOption = options.find(option => option.value === activeTab)
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
/>
)}
>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
{options.map(option => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
<span>{option.text}</span>
<DropdownMenuRadioItemIndicator />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default AppTypeFilter

View File

@@ -0,0 +1,128 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuCheckboxItemIndicator,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { cn } from '@/utils/classnames'
type CreatorOption = {
id: string
name: string
isYou?: boolean
avatarClassName: string
}
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
const creatorOptions: CreatorOption[] = [
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
]
const CreatorsFilter = () => {
const { t } = useTranslation()
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
const [keywords, setKeywords] = useState('')
const filteredCreators = useMemo(() => {
const normalizedKeywords = keywords.trim().toLowerCase()
if (!normalizedKeywords)
return creatorOptions
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
}, [keywords])
const selectedCount = selectedCreatorIds.length
const triggerLabel = selectedCount > 0
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
: t('studio.filters.creators', { ns: 'app' })
const toggleCreator = useCallback((creatorId: string) => {
setSelectedCreatorIds((prev) => {
if (prev.includes(creatorId))
return prev.filter(id => id !== creatorId)
return [...prev, creatorId]
})
}, [])
const resetCreators = useCallback(() => {
setSelectedCreatorIds([])
setKeywords('')
}, [])
return (
<DropdownMenu>
<DropdownMenuTrigger
render={(
<button
type="button"
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
/>
)}
>
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{triggerLabel}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
<div className="flex items-center gap-2 p-2 pb-1">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
/>
<button
type="button"
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={resetCreators}
>
{t('studio.filters.reset', { ns: 'app' })}
</button>
</div>
<div className="px-1 pb-1">
<DropdownMenuCheckboxItem
checked={selectedCreatorIds.length === 0}
onCheckedChange={resetCreators}
>
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{filteredCreators.map(creator => (
<DropdownMenuCheckboxItem
key={creator.id}
checked={selectedCreatorIds.includes(creator.id)}
onCheckedChange={() => toggleCreator(creator.id)}
>
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
<span className="flex min-w-0 grow items-center justify-between gap-2">
<span className="truncate">{creator.name}</span>
{creator.isYou && (
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
)}
</span>
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export default CreatorsFilter

View File

@@ -14,10 +14,20 @@ import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
export type StudioPageType = 'apps' | 'snippets'
type AppsProps = {
pageType?: StudioPageType
}
const Apps = ({
pageType = 'apps',
}: AppsProps) => {
const { t } = useTranslation()
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useDocumentTitle(pageType === 'apps'
? t('menus.apps', { ns: 'common' })
: t('tabs.snippets', { ns: 'workflow' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
@@ -101,7 +111,7 @@ const Apps = () => {
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
<List controlRefreshList={controlRefreshList} pageType={pageType} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}

View File

@@ -1,25 +1,30 @@
'use client'
import type { FC } from 'react'
import type { StudioPageType } from '.'
import type { SnippetListItem } from '@/models/snippet'
import type { App } from '@/types/app'
import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import TabSliderNew from '@/app/components/base/tab-slider-new'
import TagFilter from '@/app/components/base/tag-management/filter'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { CheckModal } from '@/hooks/use-pay'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum, AppModes } from '@/types/app'
import { getSnippetListMock } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import AppCard from './app-card'
import { AppCardSkeleton } from './app-card-skeleton'
import AppTypeFilter from './app-type-filter'
import { parseAsAppListCategory } from './app-type-filter-shared'
import CreatorsFilter from './creators-filter'
import Empty from './empty'
import Footer from './footer'
import useAppsQueryState from './hooks/use-apps-query-state'
@@ -33,25 +38,104 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
const isAppListCategory = (value: string): value is AppListCategory => {
return appListCategorySet.has(value)
const StudioRouteSwitch = ({ pageType, appsLabel, snippetsLabel }: { pageType: StudioPageType, appsLabel: string, snippetsLabel: string }) => {
return (
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
<Link
href="/apps"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'apps' && 'font-medium',
)}
>
{appsLabel}
</Link>
<Link
href="/snippets"
className={cn(
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
pageType !== 'snippets' && 'font-medium',
)}
>
{snippetsLabel}
</Link>
</div>
)
}
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
.withDefault('all')
.withOptions({ history: 'push' })
const SnippetCreateCard = () => {
const { t } = useTranslation('snippet')
return (
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
<div className="grow rounded-t-xl p-2">
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
<div className="mb-1 flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
{t('newApp.startFromBlank', { ns: 'app' })}
</div>
<div className="flex w-full items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary">
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
{t('importDSL', { ns: 'app' })}
</div>
</div>
</div>
)
}
const SnippetCard = ({
snippet,
}: {
snippet: SnippetListItem
}) => {
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{snippet.status && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
{snippet.status}
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-divider-regular text-xl text-white" style={{ background: snippet.iconBackground }}>
<span aria-hidden>{snippet.icon}</span>
</div>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
</div>
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
<div className="line-clamp-2" title={snippet.description}>
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updatedAt}</span>
<span>·</span>
<span className="truncate">{snippet.usage}</span>
</div>
</article>
</Link>
)
}
type Props = {
controlRefreshList?: number
pageType?: StudioPageType
}
const List: FC<Props> = ({
controlRefreshList = 0,
pageType = 'apps',
}) => {
const { t } = useTranslation()
const isAppsPage = pageType === 'apps'
const { systemFeatures } = useGlobalPublicStore()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
@@ -61,18 +145,21 @@ const List: FC<Props> = ({
)
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
const [searchKeywords, setSearchKeywords] = useState(keywords)
const newAppCardRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [appKeywords, setAppKeywords] = useState(keywords)
const [snippetKeywords, setSnippetKeywords] = useState('')
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const setKeywords = useCallback((keywords: string) => {
setQuery(prev => ({ ...prev, keywords }))
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const newAppCardRef = useRef<HTMLDivElement>(null)
const setKeywords = useCallback((nextKeywords: string) => {
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
}, [setQuery])
const setTagIDs = useCallback((tagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs }))
const setTagIDs = useCallback((nextTagIDs: string[]) => {
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
}, [setQuery])
const handleDSLFileDropped = useCallback((file: File) => {
@@ -83,15 +170,15 @@ const List: FC<Props> = ({
const { dragging } = useDSLDragDrop({
onDSLFileDropped: handleDSLFileDropped,
containerRef,
enabled: isCurrentWorkspaceEditor,
enabled: isAppsPage && isCurrentWorkspaceEditor,
})
const appListQueryParams = {
page: 1,
limit: 30,
name: searchKeywords,
name: appKeywords,
tag_ids: tagIDs,
is_created_by_me: isCreatedByMe,
is_created_by_me: queryIsCreatedByMe,
...(activeTab !== 'all' ? { mode: activeTab } : {}),
}
@@ -104,48 +191,40 @@ const List: FC<Props> = ({
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
} = useInfiniteAppList(appListQueryParams, {
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
})
useEffect(() => {
if (controlRefreshList > 0) {
if (isAppsPage && controlRefreshList > 0)
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
]
}, [controlRefreshList, isAppsPage, refetch])
useEffect(() => {
if (!isAppsPage)
return
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
refetch()
}
}, [refetch])
}, [isAppsPage, refetch])
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
const hasMore = isAppsPage ? (hasNextPage ?? true) : false
let observer: IntersectionObserver | undefined
if (error) {
if (observer)
observer.disconnect()
observer?.disconnect()
return
}
if (anchorRef.current && containerRef.current) {
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
const containerHeight = containerRef.current.clientHeight
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
@@ -153,110 +232,148 @@ const List: FC<Props> = ({
}, {
root: containerRef.current,
rootMargin: `${dynamicMargin}px`,
threshold: 0.1, // Trigger when 10% of the anchor element is visible
threshold: 0.1,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
}, [error, fetchNextPage, hasNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading])
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
const { run: handleAppSearch } = useDebounceFn((value: string) => {
setAppKeywords(value)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const { run: handleTagsUpdate } = useDebounceFn(() => {
setTagIDs(tagFilterValue)
const handleKeywordsChange = useCallback((value: string) => {
if (isAppsPage) {
setKeywords(value)
handleAppSearch(value)
return
}
setSnippetKeywords(value)
}, [handleAppSearch, isAppsPage, setKeywords])
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
setTagIDs(value)
}, { wait: 500 })
const handleTagsChange = (value: string[]) => {
const handleTagsChange = useCallback((value: string[]) => {
setTagFilterValue(value)
handleTagsUpdate()
}
handleTagsUpdate(value)
}, [handleTagsUpdate])
const handleCreatedByMeChange = useCallback(() => {
const newValue = !isCreatedByMe
setIsCreatedByMe(newValue)
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery])
const appItems = useMemo<App[]>(() => {
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
}, [data?.pages])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
// Show skeleton during initial load or when refetching with no previous data
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const snippetItems = useMemo(() => getSnippetListMock(), [])
const filteredSnippetItems = useMemo(() => {
const normalizedKeywords = snippetKeywords.trim().toLowerCase()
if (!normalizedKeywords)
return snippetItems
return snippetItems.filter(item =>
item.name.toLowerCase().includes(normalizedKeywords)
|| item.description.toLowerCase().includes(normalizedKeywords),
)
}, [snippetItems, snippetKeywords])
const showSkeleton = isAppsPage && (isLoading || (isFetching && data?.pages?.length === 0))
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
const hasAnySnippet = filteredSnippetItems.length > 0
const currentKeywords = isAppsPage ? keywords : snippetKeywords
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
{dragging && (
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
</div>
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
)}
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
<TabSliderNew
value={activeTab}
onChange={(nextValue) => {
if (isAppListCategory(nextValue))
setActiveTab(nextValue)
}}
options={options}
/>
<div className="flex items-center gap-2">
<CheckboxWithLabel
className="mr-2"
label={t('showMyCreatedAppsOnly', { ns: 'app' })}
isChecked={isCreatedByMe}
onChange={handleCreatedByMeChange}
<div className="flex flex-wrap items-center gap-2">
<StudioRouteSwitch
pageType={pageType}
appsLabel={t('studio.apps', { ns: 'app' })}
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
/>
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
{isAppsPage && (
<AppTypeFilter
activeTab={activeTab}
onChange={(value) => {
void setActiveTab(value)
}}
/>
)}
<CreatorsFilter />
{isAppsPage && (
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
)}
</div>
<div className="flex items-center gap-2">
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-[200px]"
value={keywords}
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
value={currentKeywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
</div>
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
!hasAnyApp && 'overflow-hidden',
isAppsPage && !hasAnyApp && 'overflow-hidden',
)}
>
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
isAppsPage
? (
<NewAppCard
ref={newAppCardRef}
isLoading={isLoadingCurrentWorkspace}
onSuccess={refetch}
selectedAppType={activeTab}
className={cn(!hasAnyApp && 'z-10')}
/>
)
: <SnippetCreateCard />
)}
{(() => {
if (showSkeleton)
return <AppCardSkeleton count={6} />
if (hasAnyApp) {
return pages.flatMap(({ data: apps }) => apps).map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))
}
{showSkeleton && <AppCardSkeleton count={6} />}
// No apps - show empty state
return <Empty />
})()}
{isFetchingNextPage && (
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
<AppCard key={app.id} app={app} onRefresh={refetch} />
))}
{!showSkeleton && !isAppsPage && hasAnySnippet && filteredSnippetItems.map(snippet => (
<SnippetCard key={snippet.id} snippet={snippet} />
))}
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
</div>
)}
{isAppsPage && isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}
</div>
{isCurrentWorkspaceEditor && (
{isAppsPage && isCurrentWorkspaceEditor && (
<div
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
className={cn(
'flex items-center justify-center gap-2 py-4',
dragging ? 'text-text-accent' : 'text-text-quaternary',
)}
role="region"
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
>
@@ -264,17 +381,18 @@ const List: FC<Props> = ({
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
</div>
)}
{!systemFeatures.branding.enabled && (
<Footer />
)}
<CheckModal />
<div ref={anchorRef} className="h-0"> </div>
{showTagManagementModal && (
{isAppsPage && showTagManagementModal && (
<TagManagementModal type="app" show={showTagManagementModal} />
)}
</div>
{showCreateFromDSLModal && (
{isAppsPage && showCreateFromDSLModal && (
<CreateFromDSLModal
show={showCreateFromDSLModal}
onClose={() => {

View File

@@ -1,4 +0,0 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z" fill="#676F83"/>
<path d="M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z" fill="#676F83"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-down-round-fill">
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="currentColor"/>
<path id="Vector" d="M6.02913 6.23572C5.08582 6.23572 4.56482 7.33027 5.15967 8.06239L7.13093 10.4885C7.57922 11.0403 8.42149 11.0403 8.86986 10.4885L10.8411 8.06239C11.4359 7.33027 10.9149 6.23572 9.97158 6.23572H6.02913Z" fill="#101828"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 380 B

View File

@@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="currentColor"/>
<path id="Solid" fill-rule="evenodd" clip-rule="evenodd" d="M8.00008 0.666016C3.94999 0.666016 0.666748 3.94926 0.666748 7.99935C0.666748 12.0494 3.94999 15.3327 8.00008 15.3327C12.0502 15.3327 15.3334 12.0494 15.3334 7.99935C15.3334 3.94926 12.0502 0.666016 8.00008 0.666016ZM10.4715 5.52794C10.7318 5.78829 10.7318 6.2104 10.4715 6.47075L8.94289 7.99935L10.4715 9.52794C10.7318 9.78829 10.7318 10.2104 10.4715 10.4708C10.2111 10.7311 9.78903 10.7311 9.52868 10.4708L8.00008 8.94216L6.47149 10.4708C6.21114 10.7311 5.78903 10.7311 5.52868 10.4708C5.26833 10.2104 5.26833 9.78829 5.52868 9.52794L7.05727 7.99935L5.52868 6.47075C5.26833 6.2104 5.26833 5.78829 5.52868 5.52794C5.78903 5.26759 6.21114 5.26759 6.47149 5.52794L8.00008 7.05654L9.52868 5.52794C9.78903 5.26759 10.2111 5.26759 10.4715 5.52794Z" fill="#98A2B3"/>
</svg>

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 925 B

View File

@@ -1,35 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "10",
"height": "10",
"viewBox": "0 0 10 10",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M2 5C2 3.44487 2.58482 1.98537 3.54004 1.04932C2.17681 1.34034 1 2.90001 1 5C1 7.09996 2.17685 8.65912 3.54004 8.9502C2.58496 8.01413 2 6.55501 2 5ZM3 5C3 7.33338 4.4528 9 6 9C7.5472 9 9 7.33338 9 5C9 2.66664 7.5472 1 6 1C4.4528 1 3 2.66664 3 5ZM10 5C10 7.63722 8.3188 10 6 10H4C1.6812 10 0 7.63722 0 5C0 2.3628 1.6812 0 4 0H6C8.3188 0 10 2.3628 10 5Z",
"fill": "currentColor"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"d": "M6.71519 4.09259L6.45385 3.18667C6.42141 3.07421 6.34037 3 6.25 3C6.15963 3 6.07859 3.07421 6.04615 3.18667L5.78481 4.09259C5.74675 4.22464 5.66849 4.32899 5.56945 4.37978L4.88999 4.7282C4.80565 4.77146 4.75 4.87951 4.75 5C4.75 5.12049 4.80565 5.22854 4.88999 5.2718L5.56945 5.62022C5.66849 5.67101 5.74675 5.77536 5.78481 5.90741L6.04615 6.81333C6.07859 6.92579 6.15963 7 6.25 7C6.34037 7 6.42141 6.92579 6.45385 6.81333L6.71519 5.90741C6.75325 5.77536 6.83151 5.67101 6.93055 5.62022L7.61001 5.2718C7.69435 5.22854 7.75 5.12049 7.75 5C7.75 4.87951 7.69435 4.77146 7.61001 4.7282L6.93055 4.37978C6.83151 4.32899 6.75325 4.22464 6.71519 4.09259Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "CreditsCoin"
}

View File

@@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './CreditsCoin.json'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'CreditsCoin'
export default Icon

View File

@@ -1,6 +1,5 @@
export { default as Balance } from './Balance'
export { default as CoinsStacked01 } from './CoinsStacked01'
export { default as CreditsCoin } from './CreditsCoin'
export { default as GoldCoin } from './GoldCoin'
export { default as ReceiptList } from './ReceiptList'
export { default as Tag01 } from './Tag01'

View File

@@ -0,0 +1,25 @@
import type { InitOptions } from 'modern-monaco'
export const LIGHT_THEME_ID = 'light-plus'
export const DARK_THEME_ID = 'dark-plus'
const DEFAULT_INIT_OPTIONS: InitOptions = {
defaultTheme: DARK_THEME_ID,
themes: [
LIGHT_THEME_ID,
DARK_THEME_ID,
],
}
let monacoInitPromise: Promise<typeof import('modern-monaco/editor-core') | null> | null = null
export const initMonaco = async () => {
if (!monacoInitPromise) {
monacoInitPromise = (async () => {
const { init } = await import('modern-monaco')
return init(DEFAULT_INIT_OPTIONS)
})()
}
return monacoInitPromise
}

View File

@@ -0,0 +1,250 @@
'use client'
import type { editor as MonacoEditor } from 'modern-monaco/editor-core'
import type { FC } from 'react'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import { DARK_THEME_ID, initMonaco, LIGHT_THEME_ID } from './init'
type ModernMonacoEditorProps = {
value: string
language: string
readOnly?: boolean
options?: MonacoEditor.IEditorOptions
onChange?: (value: string) => void
onFocus?: () => void
onBlur?: () => void
onReady?: (editor: MonacoEditor.IStandaloneCodeEditor, monaco: typeof import('modern-monaco/editor-core')) => void
loading?: React.ReactNode
className?: string
style?: React.CSSProperties
}
type MonacoModule = typeof import('modern-monaco/editor-core')
type EditorCallbacks = Pick<ModernMonacoEditorProps, 'onBlur' | 'onChange' | 'onFocus' | 'onReady'>
type EditorSetup = {
editorOptions: MonacoEditor.IEditorOptions
language: string
resolvedTheme: string
}
const syncEditorValue = (
editor: MonacoEditor.IStandaloneCodeEditor,
monaco: MonacoModule,
model: MonacoEditor.ITextModel,
value: string,
preventTriggerChangeEventRef: React.RefObject<boolean>,
) => {
const currentValue = model.getValue()
if (currentValue === value)
return
if (editor.getOption(monaco.editor.EditorOption.readOnly)) {
editor.setValue(value)
return
}
preventTriggerChangeEventRef.current = true
try {
editor.executeEdits('', [{
range: model.getFullModelRange(),
text: value,
forceMoveMarkers: true,
}])
editor.pushUndoStop()
}
finally {
preventTriggerChangeEventRef.current = false
}
}
const bindEditorCallbacks = (
editor: MonacoEditor.IStandaloneCodeEditor,
monaco: MonacoModule,
callbacksRef: React.RefObject<EditorCallbacks>,
preventTriggerChangeEventRef: React.RefObject<boolean>,
) => {
const changeDisposable = editor.onDidChangeModelContent(() => {
if (preventTriggerChangeEventRef.current)
return
callbacksRef.current.onChange?.(editor.getValue())
})
const keydownDisposable = editor.onKeyDown((event) => {
const { key, code } = event.browserEvent
if (key === ' ' || code === 'Space')
event.stopPropagation()
})
const focusDisposable = editor.onDidFocusEditorText(() => {
callbacksRef.current.onFocus?.()
})
const blurDisposable = editor.onDidBlurEditorText(() => {
callbacksRef.current.onBlur?.()
})
return () => {
blurDisposable.dispose()
focusDisposable.dispose()
keydownDisposable.dispose()
changeDisposable.dispose()
}
}
export const ModernMonacoEditor: FC<ModernMonacoEditorProps> = ({
value,
language,
readOnly = false,
options,
onChange,
onFocus,
onBlur,
onReady,
loading,
className,
style,
}) => {
const { theme: appTheme } = useTheme()
const resolvedTheme = appTheme === Theme.light ? LIGHT_THEME_ID : DARK_THEME_ID
const [isEditorReady, setIsEditorReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null)
const modelRef = useRef<MonacoEditor.ITextModel | null>(null)
const monacoRef = useRef<MonacoModule | null>(null)
const preventTriggerChangeEventRef = useRef(false)
const valueRef = useRef(value)
const callbacksRef = useRef<EditorCallbacks>({ onChange, onFocus, onBlur, onReady })
const editorOptions = useMemo<MonacoEditor.IEditorOptions>(() => ({
automaticLayout: true,
readOnly,
domReadOnly: true,
minimap: { enabled: false },
wordWrap: 'on',
fixedOverflowWidgets: true,
tabFocusMode: false,
...options,
}), [readOnly, options])
const setupRef = useRef<EditorSetup>({
editorOptions,
language,
resolvedTheme,
})
useEffect(() => {
valueRef.current = value
}, [value])
useEffect(() => {
callbacksRef.current = { onChange, onFocus, onBlur, onReady }
}, [onChange, onFocus, onBlur, onReady])
useEffect(() => {
setupRef.current = {
editorOptions,
language,
resolvedTheme,
}
}, [editorOptions, language, resolvedTheme])
useEffect(() => {
let disposed = false
let cleanup: (() => void) | undefined
const setup = async () => {
const monaco = await initMonaco()
if (!monaco || disposed || !containerRef.current)
return
monacoRef.current = monaco
const editor = monaco.editor.create(containerRef.current, setupRef.current.editorOptions)
editorRef.current = editor
const model = monaco.editor.createModel(valueRef.current, setupRef.current.language)
modelRef.current = model
editor.setModel(model)
monaco.editor.setTheme(setupRef.current.resolvedTheme)
const disposeCallbacks = bindEditorCallbacks(
editor,
monaco,
callbacksRef,
preventTriggerChangeEventRef,
)
const resizeObserver = new ResizeObserver(() => {
editor.layout()
})
resizeObserver.observe(containerRef.current)
callbacksRef.current.onReady?.(editor, monaco)
setIsEditorReady(true)
cleanup = () => {
resizeObserver.disconnect()
disposeCallbacks()
editor.dispose()
model.dispose()
setIsEditorReady(false)
}
}
setup()
return () => {
disposed = true
cleanup?.()
}
}, [])
useEffect(() => {
const editor = editorRef.current
if (!editor)
return
editor.updateOptions(editorOptions)
}, [editorOptions])
useEffect(() => {
const monaco = monacoRef.current
const model = modelRef.current
if (!monaco || !model)
return
monaco.editor.setModelLanguage(model, language)
}, [language])
useEffect(() => {
const monaco = monacoRef.current
if (!monaco)
return
monaco.editor.setTheme(resolvedTheme)
}, [resolvedTheme])
useEffect(() => {
const editor = editorRef.current
const monaco = monacoRef.current
const model = modelRef.current
if (!editor || !monaco || !model)
return
syncEditorValue(editor, monaco, model, value, preventTriggerChangeEventRef)
}, [value])
return (
<div
className={cn('relative h-full w-full', className)}
style={style}
>
<div
ref={containerRef}
className="h-full w-full"
/>
{!isEditorReady && !!loading && (
<div className="absolute inset-0 flex items-center justify-center">
{loading}
</div>
)}
</div>
)
}

View File

@@ -53,7 +53,7 @@ const createSelectorWithTransientPrefix = (prefix: string, suffix: string): stri
}
const hasErrorIcon = (container: HTMLElement) => {
return container.querySelector('svg.text-text-warning') !== null
return container.querySelector('svg.text-text-destructive') !== null
}
const renderVariableBlock = (props: {

View File

@@ -14,7 +14,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow, useStoreApi } from 'reactflow'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import {
@@ -28,7 +28,6 @@ import {
UPDATE_WORKFLOW_NODES_MAP,
} from './index'
import { WorkflowVariableBlockNode } from './node'
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
type WorkflowVariableBlockComponentProps = {
nodeKey: string
@@ -69,8 +68,6 @@ const WorkflowVariableBlockComponent = ({
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
const isException = isExceptionVariable(varName, node?.type)
const sourceNodeId = variables[isRagVar ? 1 : 0]
const isLlmModelInstalled = useLlmModelPluginInstalled(sourceNodeId, localWorkflowNodesMap)
const variableValid = useMemo(() => {
let variableValid = true
const isEnv = isENV(variables)
@@ -147,13 +144,7 @@ const WorkflowVariableBlockComponent = ({
handleVariableJump()
}}
isExceptionVariable={isException}
errorMsg={
!variableValid
? t('errorMsg.invalidVariable', { ns: 'workflow' })
: !isLlmModelInstalled
? t('errorMsg.modelPluginNotInstalled', { ns: 'workflow' })
: undefined
}
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
isSelected={isSelected}
ref={ref}
notShowFullPath={isShowAPart}
@@ -164,9 +155,9 @@ const WorkflowVariableBlockComponent = ({
return Item
return (
<Tooltip>
<TooltipTrigger disabled={!isShowAPart} render={<div>{Item}</div>} />
<TooltipContent variant="plain">
<Tooltip
noDecoration
popupContent={(
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
@@ -178,7 +169,10 @@ const WorkflowVariableBlockComponent = ({
: Type.string}
nodeType={node?.type}
/>
</TooltipContent>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
}

View File

@@ -1,75 +0,0 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { renderHook } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import { useLlmModelPluginInstalled } from './use-llm-model-plugin-installed'
let mockModelProviders: Array<{ provider: string }> = []
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: <T>(selector: (state: { modelProviders: Array<{ provider: string }> }) => T): T =>
selector({ modelProviders: mockModelProviders }),
}))
const createWorkflowNodesMap = (node: Record<string, unknown>): WorkflowNodesMap =>
({
target: {
title: 'Target',
type: BlockEnum.Start,
...node,
},
} as unknown as WorkflowNodesMap)
describe('useLlmModelPluginInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelProviders = []
})
it('should return true when the node is missing', () => {
const { result } = renderHook(() => useLlmModelPluginInstalled('target', undefined))
expect(result.current).toBe(true)
})
it('should return true when the node is not an LLM node', () => {
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.Start,
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return true when the matching model plugin is installed', () => {
mockModelProviders = [
{ provider: 'langgenius/openai/openai' },
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(true)
})
it('should return false when the matching model plugin is not installed', () => {
mockModelProviders = [
{ provider: 'langgenius/anthropic/claude' },
]
const workflowNodesMap = createWorkflowNodesMap({
id: 'target',
type: BlockEnum.LLM,
modelProvider: 'langgenius/openai/gpt-4.1',
})
const { result } = renderHook(() => useLlmModelPluginInstalled('target', workflowNodesMap))
expect(result.current).toBe(false)
})
})

View File

@@ -1,23 +0,0 @@
import type { WorkflowNodesMap } from '@/app/components/base/prompt-editor/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { extractPluginId } from '@/app/components/workflow/utils/plugin'
import { useProviderContextSelector } from '@/context/provider-context'
export function useLlmModelPluginInstalled(
nodeId: string,
workflowNodesMap: WorkflowNodesMap | undefined,
): boolean {
const node = workflowNodesMap?.[nodeId]
const modelProvider = node?.type === BlockEnum.LLM
? node.modelProvider
: undefined
const modelPluginId = modelProvider ? extractPluginId(modelProvider) : undefined
return useProviderContextSelector((state) => {
if (!modelPluginId)
return true
return state.modelProviders.some(p =>
extractPluginId(p.provider) === modelPluginId,
)
})
}

View File

@@ -73,7 +73,7 @@ export type GetVarType = (payload: {
export type WorkflowVariableBlockType = {
show?: boolean
variables?: NodeOutPutVar[]
workflowNodesMap?: WorkflowNodesMap
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
onInsert?: () => void
onDelete?: () => void
getVarType?: GetVarType
@@ -81,14 +81,12 @@ export type WorkflowVariableBlockType = {
onManageInputField?: () => void
}
export type WorkflowNodesMap = Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'> & { modelProvider?: string }>
export type HITLInputBlockType = {
show?: boolean
nodeId: string
formInputs?: FormInputItem[]
variables?: NodeOutPutVar[]
workflowNodesMap?: WorkflowNodesMap
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
getVarType?: GetVarType
onFormInputsChange?: (inputs: FormInputItem[]) => void
onFormInputItemRemove: (varName: string) => void

View File

@@ -1,57 +0,0 @@
import type { ComponentType, InputHTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react'
const mockNotify = vi.fn()
type AutosizeInputProps = InputHTMLAttributes<HTMLInputElement> & {
inputClassName?: string
}
const MockAutosizeInput: ComponentType<AutosizeInputProps> = ({ inputClassName, ...props }) => (
<input data-testid="autosize-input" className={inputClassName} {...props} />
)
describe('TagInput autosize interop', () => {
afterEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('should support a namespace-style default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: {
default: MockAutosizeInput,
},
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should support a direct default export from react-18-input-autosize', async () => {
vi.doMock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.doMock('react-18-input-autosize', () => ({
default: MockAutosizeInput,
}))
const { default: TagInput } = await import('../index')
render(<TagInput items={[]} onChange={vi.fn()} />)
expect(screen.getByTestId('autosize-input')).toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})

View File

@@ -1,14 +1,10 @@
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import { useCallback, useState } from 'react'
import _AutosizeInput from 'react-18-input-autosize'
import AutosizeInput from 'react-18-input-autosize'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { cn } from '@/utils/classnames'
// CJS/ESM interop: Turbopack may resolve the module namespace object instead of the default export
// eslint-disable-next-line ts/no-explicit-any
const AutosizeInput = ('default' in (_AutosizeInput as any) ? (_AutosizeInput as any).default : _AutosizeInput) as typeof _AutosizeInput
type TagInputProps = {
items: string[]
onChange: (items: string[]) => void

View File

@@ -46,24 +46,20 @@ type DialogContentProps = {
children: React.ReactNode
className?: string
overlayClassName?: string
backdropProps?: React.ComponentPropsWithoutRef<typeof BaseDialog.Backdrop>
}
export function DialogContent({
children,
className,
overlayClassName,
backdropProps,
}: DialogContentProps) {
return (
<DialogPortal>
<BaseDialog.Backdrop
{...backdropProps}
className={cn(
'fixed inset-0 z-[1002] bg-background-overlay',
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
overlayClassName,
backdropProps?.className,
)}
/>
<BaseDialog.Popup

View File

@@ -1,93 +0,0 @@
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { UsagePlanInfo } from '../../type'
import { render } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../../type'
import Pricing from '../index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('../header', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<button data-testid="pricing-header-close" onClick={onClose}>close</button>
),
}))
vi.mock('../plan-switcher', () => ({
default: () => <div>plan-switcher</div>,
}))
vi.mock('../plans', () => ({
default: () => <div>plans</div>,
}))
vi.mock('../footer', () => ({
default: () => <div>footer</div>,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
}))
const buildUsage = (): UsagePlanInfo => ({
buildApps: 0,
teamMembers: 0,
annotatedResponse: 0,
documentsUploadQuota: 0,
apiRateLimit: 0,
triggerEvents: 0,
vectorSpace: 0,
})
describe('Pricing dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
;(useAppContext as Mock).mockReturnValue({ isCurrentWorkspaceManager: true })
;(useProviderContext as Mock).mockReturnValue({
plan: {
type: Plan.sandbox,
usage: buildUsage(),
total: buildUsage(),
},
})
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(<Pricing onCancel={onCancel} />)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '..'
import Footer from '../footer'
import { CategoryEnum } from '../types'
vi.mock('next/link', () => ({
default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => (

View File

@@ -1,16 +1,7 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { Dialog } from '@/app/components/base/ui/dialog'
import Header from '../header'
function renderHeader(onClose: () => void) {
return render(
<Dialog open>
<Header onClose={onClose} />
</Dialog>,
)
}
describe('Header', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -20,7 +11,7 @@ describe('Header', () => {
it('should render title and description translations', () => {
const handleClose = vi.fn()
renderHeader(handleClose)
render(<Header onClose={handleClose} />)
expect(screen.getByText('billing.plansCommon.title.plans')).toBeInTheDocument()
expect(screen.getByText('billing.plansCommon.title.description')).toBeInTheDocument()
@@ -31,7 +22,7 @@ describe('Header', () => {
describe('Props', () => {
it('should invoke onClose when close button is clicked', () => {
const handleClose = vi.fn()
renderHeader(handleClose)
render(<Header onClose={handleClose} />)
fireEvent.click(screen.getByRole('button'))
@@ -41,7 +32,7 @@ describe('Header', () => {
describe('Edge Cases', () => {
it('should render structural elements with translation keys', () => {
const { container } = renderHeader(vi.fn())
const { container } = render(<Header onClose={vi.fn()} />)
expect(container.querySelector('span')).toBeInTheDocument()
expect(container.querySelector('p')).toBeInTheDocument()

View File

@@ -74,11 +74,15 @@ describe('Pricing', () => {
})
describe('Props', () => {
it('should allow switching categories', () => {
render(<Pricing onCancel={vi.fn()} />)
it('should allow switching categories and handle esc key', () => {
const handleCancel = vi.fn()
render(<Pricing onCancel={handleCancel} />)
fireEvent.click(screen.getByText('billing.plansCommon.self'))
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
expect(handleCancel).toHaveBeenCalled()
})
})

View File

@@ -1,9 +1,10 @@
import type { Category } from './types'
import type { Category } from '.'
import { RiArrowRightUpLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { CategoryEnum } from './types'
import { CategoryEnum } from '.'
type FooterProps = {
pricingPageURL: string
@@ -33,7 +34,7 @@ const Footer = ({
>
{t('plansCommon.comparePlanAndFeatures', { ns: 'billing' })}
</Link>
<span aria-hidden="true" className="i-ri-arrow-right-up-line size-4" />
<RiArrowRightUpLine className="size-4" />
</span>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
@@ -38,7 +39,7 @@ const Header = ({
className="absolute bottom-[40.5px] right-[-18px] z-10 size-9 rounded-full p-2"
onClick={onClose}
>
<span aria-hidden="true" className="i-ri-close-line size-5" />
<RiCloseLine className="size-5" />
</Button>
</div>
</div>

View File

@@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import type { Category } from './types'
import { useKeyPress } from 'ahooks'
import * as React from 'react'
import { useState } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { createPortal } from 'react-dom'
import { useAppContext } from '@/context/app-context'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
@@ -13,7 +13,13 @@ import Header from './header'
import PlanSwitcher from './plan-switcher'
import { PlanRange } from './plan-switcher/plan-range-switcher'
import Plans from './plans'
import { CategoryEnum } from './types'
export enum CategoryEnum {
CLOUD = 'cloud',
SELF = 'self',
}
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF
type PricingProps = {
onCancel: () => void
@@ -27,47 +33,42 @@ const Pricing: FC<PricingProps> = ({
const [planRange, setPlanRange] = React.useState<PlanRange>(PlanRange.monthly)
const [currentCategory, setCurrentCategory] = useState<Category>(CategoryEnum.CLOUD)
const canPay = isCurrentWorkspaceManager
useKeyPress(['esc'], onCancel)
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
: 'https://dify.ai/pricing#plans-and-features'
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
return createPortal(
<div
className="fixed inset-0 bottom-0 left-0 right-0 top-0 z-[1000] overflow-auto bg-saas-background"
onClick={e => e.stopPropagation()}
>
<DialogContent
className="inset-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-auto rounded-none border-none bg-saas-background p-0 shadow-none"
>
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
<div className="relative grid min-h-full min-w-[1200px] grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
<div className="absolute -top-12 left-0 right-0 -z-10">
<NoiseTop />
</div>
</DialogContent>
</Dialog>
<Header onClose={onCancel} />
<PlanSwitcher
currentCategory={currentCategory}
onChangeCategory={setCurrentCategory}
currentPlanRange={planRange}
onChangePlanRange={setPlanRange}
/>
<Plans
plan={plan}
currentPlan={currentCategory}
planRange={planRange}
canPay={canPay}
/>
<Footer pricingPageURL={pricingPageURL} currentCategory={currentCategory} />
<div className="absolute -bottom-12 left-0 right-0 -z-10">
<NoiseBottom />
</div>
</div>
</div>,
document.body,
)
}
export default React.memo(Pricing)

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { CategoryEnum } from '../../types'
import { CategoryEnum } from '../../index'
import PlanSwitcher from '../index'
import { PlanRange } from '../plan-range-switcher'

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react'
import type { Category } from '../types'
import type { Category } from '../index'
import type { PlanRange } from './plan-range-switcher'
import * as React from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -1,6 +0,0 @@
export enum CategoryEnum {
CLOUD = 'cloud',
SELF = 'self',
}
export type Category = CategoryEnum.CLOUD | CategoryEnum.SELF

View File

@@ -1,5 +1,5 @@
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -55,21 +55,6 @@ const createMockCrawlResultItem = (overrides: Partial<CrawlResultItem> = {}): Cr
...overrides,
})
const createDeferred = <T,>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return {
promise,
resolve,
reject,
}
}
// FireCrawl Component Tests
describe('FireCrawl', () => {
@@ -232,7 +217,7 @@ describe('FireCrawl', () => {
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
@@ -256,7 +241,7 @@ describe('FireCrawl', () => {
await user.click(runButton)
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
expect(mockCreateFirecrawlTask).toHaveBeenCalled()
})
})
})
@@ -292,10 +277,6 @@ describe('FireCrawl', () => {
}),
})
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should call onJobIdChange with job_id from API response', async () => {
@@ -320,10 +301,6 @@ describe('FireCrawl', () => {
await waitFor(() => {
expect(mockOnJobIdChange).toHaveBeenCalledWith('my-job-123')
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should remove empty max_depth from crawlOptions before sending to API', async () => {
@@ -357,23 +334,11 @@ describe('FireCrawl', () => {
}),
})
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
it('should show loading state while running', async () => {
const user = userEvent.setup()
const createTaskDeferred = createDeferred<{ job_id: string }>()
mockCreateFirecrawlTask.mockImplementation(() => createTaskDeferred.promise)
mockCheckFirecrawlTaskStatus.mockResolvedValueOnce({
status: 'completed',
data: [],
total: 0,
current: 0,
time_consuming: 1,
})
mockCreateFirecrawlTask.mockImplementation(() => new Promise(() => {})) // Never resolves
render(<FireCrawl {...defaultProps} />)
@@ -387,14 +352,6 @@ describe('FireCrawl', () => {
await waitFor(() => {
expect(runButton).not.toHaveTextContent(/run/i)
})
await act(async () => {
createTaskDeferred.resolve({ job_id: 'test-job-id' })
})
await waitFor(() => {
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
})
})
})
@@ -699,7 +656,7 @@ describe('FireCrawl', () => {
await waitFor(() => {
// Total should be capped to limit (5)
expect(mockOnCheckedCrawlResultChange).toHaveBeenCalledWith([])
expect(mockCheckFirecrawlTaskStatus).toHaveBeenCalled()
})
})
})

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@@ -35,22 +35,6 @@ enum Step {
finished = 'finished',
}
type CrawlState = {
current: number
total: number
data: CrawlResultItem[]
time_consuming: number | string
}
type CrawlFinishedResult = {
isCancelled?: boolean
isError: boolean
errorMessage?: string
data: Partial<CrawlState> & {
data: CrawlResultItem[]
}
}
const FireCrawl: FC<Props> = ({
onPreview,
checkedCrawlResult,
@@ -62,16 +46,10 @@ const FireCrawl: FC<Props> = ({
const { t } = useTranslation()
const [step, setStep] = useState<Step>(Step.init)
const [controlFoldOptions, setControlFoldOptions] = useState<number>(0)
const isMountedRef = useRef(true)
useEffect(() => {
if (step !== Step.init)
setControlFoldOptions(Date.now())
}, [step])
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, [])
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const handleSetting = useCallback(() => {
setShowAccountSettingModal({
@@ -107,19 +85,16 @@ const FireCrawl: FC<Props> = ({
const isInit = step === Step.init
const isCrawlFinished = step === Step.finished
const isRunning = step === Step.running
const [crawlResult, setCrawlResult] = useState<CrawlState | undefined>(undefined)
const [crawlResult, setCrawlResult] = useState<{
current: number
total: number
data: CrawlResultItem[]
time_consuming: number | string
} | undefined>(undefined)
const [crawlErrorMessage, setCrawlErrorMessage] = useState('')
const showError = isCrawlFinished && crawlErrorMessage
const waitForCrawlFinished = useCallback(async (jobId: string): Promise<CrawlFinishedResult> => {
const cancelledResult: CrawlFinishedResult = {
isCancelled: true,
isError: false,
data: {
data: [],
},
}
const waitForCrawlFinished = useCallback(async (jobId: string) => {
try {
const res = await checkFirecrawlTaskStatus(jobId) as any
if (res.status === 'completed') {
@@ -129,7 +104,7 @@ const FireCrawl: FC<Props> = ({
...res,
total: Math.min(res.total, Number.parseFloat(crawlOptions.limit as string)),
},
} satisfies CrawlFinishedResult
}
}
if (res.status === 'error' || !res.status) {
// can't get the error message from the firecrawl api
@@ -139,14 +114,12 @@ const FireCrawl: FC<Props> = ({
data: {
data: [],
},
} satisfies CrawlFinishedResult
}
}
res.data = res.data.map((item: any) => ({
...item,
content: item.markdown,
}))
if (!isMountedRef.current)
return cancelledResult
// update the progress
setCrawlResult({
...res,
@@ -154,21 +127,17 @@ const FireCrawl: FC<Props> = ({
})
onCheckedCrawlResultChange(res.data || []) // default select the crawl result
await sleep(2500)
if (!isMountedRef.current)
return cancelledResult
return await waitForCrawlFinished(jobId)
}
catch (e: any) {
if (!isMountedRef.current)
return cancelledResult
const errorBody = typeof e?.json === 'function' ? await e.json() : undefined
const errorBody = await e.json()
return {
isError: true,
errorMessage: errorBody?.message,
errorMessage: errorBody.message,
data: {
data: [],
},
} satisfies CrawlFinishedResult
}
}
}, [crawlOptions.limit, onCheckedCrawlResultChange])
@@ -193,31 +162,24 @@ const FireCrawl: FC<Props> = ({
url,
options: passToServerCrawlOptions,
}) as any
if (!isMountedRef.current)
return
const jobId = res.job_id
onJobIdChange(jobId)
const { isCancelled, isError, data, errorMessage } = await waitForCrawlFinished(jobId)
if (isCancelled || !isMountedRef.current)
return
const { isError, data, errorMessage } = await waitForCrawlFinished(jobId)
if (isError) {
setCrawlErrorMessage(errorMessage || t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' }))
}
else {
setCrawlResult(data as CrawlState)
setCrawlResult(data)
onCheckedCrawlResultChange(data.data || []) // default select the crawl result
setCrawlErrorMessage('')
}
}
catch (e) {
if (!isMountedRef.current)
return
setCrawlErrorMessage(t(`${I18N_PREFIX}.unknownError`, { ns: 'datasetCreation' })!)
console.log(e)
}
finally {
if (isMountedRef.current)
setStep(Step.finished)
setStep(Step.finished)
}
}, [checkValid, crawlOptions, onJobIdChange, t, waitForCrawlFinished, onCheckedCrawlResultChange])

View File

@@ -204,7 +204,7 @@ const CSVUploader: FC<Props> = ({
/>
<div ref={dropRef}>
{!file && (
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
<div className={cn('flex h-20 items-center rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur text-sm font-normal', dragging && 'border border-divider-subtle bg-components-panel-on-panel-item-bg-hover')}>
<div className="flex w-full items-center justify-center space-x-2">
<CSVIcon className="shrink-0" />
<div className="text-text-secondary">

View File

@@ -58,7 +58,7 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
className="system-xs-semibold text-text-accent"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChildChunk?.()
@@ -120,11 +120,11 @@ const NewChildSegmentModal: FC<NewChildSegmentModalProps> = ({
<div className="flex h-full flex-col">
<div className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}>
<div className="flex flex-col">
<div className="text-text-primary system-xl-semibold">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
<div className="system-xl-semibold text-text-primary">{t('segment.addChildChunk', { ns: 'datasetDocuments' })}</div>
<div className="flex items-center gap-x-2">
<SegmentIndexTag label={t('segment.newChildChunk', { ns: 'datasetDocuments' }) as string} />
<Dot />
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
</div>
</div>
<div className="flex items-center">

View File

@@ -61,7 +61,7 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
<Divider type="vertical" className="mx-1 h-3 bg-divider-regular" />
<button
type="button"
className="text-text-accent system-xs-semibold"
className="system-xs-semibold text-text-accent"
onClick={() => {
clearTimeout(refreshTimer.current)
viewNewlyAddedChunk()
@@ -158,13 +158,13 @@ const NewSegmentModal: FC<NewSegmentModalProps> = ({
className={cn('flex items-center justify-between', fullScreen ? 'border border-divider-subtle py-3 pl-6 pr-4' : 'pl-4 pr-3 pt-3')}
>
<div className="flex flex-col">
<div className="text-text-primary system-xl-semibold">
<div className="system-xl-semibold text-text-primary">
{t('segment.addChunk', { ns: 'datasetDocuments' })}
</div>
<div className="flex items-center gap-x-2">
<SegmentIndexTag label={t('segment.newChunk', { ns: 'datasetDocuments' })!} />
<Dot />
<span className="text-text-tertiary system-xs-medium">{wordCountText}</span>
<span className="system-xs-medium text-text-tertiary">{wordCountText}</span>
</div>
</div>
<div className="flex items-center">

View File

@@ -100,10 +100,10 @@ vi.mock('@/app/components/datasets/create/step-two', () => ({
}))
vi.mock('@/app/components/header/account-setting', () => ({
default: ({ activeTab, onCancelAction }: { activeTab?: string, onCancelAction?: () => void }) => (
default: ({ activeTab, onCancel }: { activeTab?: string, onCancel?: () => void }) => (
<div data-testid="account-setting">
<span data-testid="active-tab">{activeTab}</span>
<button onClick={onCancelAction} data-testid="close-setting">Close</button>
<button onClick={onCancel} data-testid="close-setting">Close</button>
</div>
),
}))

View File

@@ -1,4 +1,3 @@
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type {
CrawlOptions,
@@ -20,7 +19,6 @@ import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import StepTwo from '@/app/components/datasets/create/step-two'
import AccountSetting from '@/app/components/header/account-setting'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import DatasetDetailContext from '@/context/dataset-detail'
@@ -35,13 +33,8 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
const { t } = useTranslation()
const router = useRouter()
const [isShowSetAPIKey, { setTrue: showSetAPIKey, setFalse: hideSetAPIkey }] = useBoolean()
const [accountSettingTab, setAccountSettingTab] = React.useState<AccountSettingTab>(ACCOUNT_SETTING_TAB.PROVIDER)
const { indexingTechnique, dataset } = useContext(DatasetDetailContext)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const handleOpenAccountSetting = React.useCallback(() => {
setAccountSettingTab(ACCOUNT_SETTING_TAB.PROVIDER)
showSetAPIKey()
}, [showSetAPIKey])
const invalidDocumentList = useInvalidDocumentList(datasetId)
const invalidDocumentDetail = useInvalidDocumentDetail()
@@ -142,7 +135,7 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
{dataset && documentDetail && (
<StepTwo
isAPIKeySet={!!embeddingsDefaultModel}
onSetting={handleOpenAccountSetting}
onSetting={showSetAPIKey}
datasetId={datasetId}
dataSourceType={documentDetail.data_source_type as DataSourceType}
notionPages={currentPage ? [currentPage as unknown as NotionPage] : []}
@@ -162,9 +155,8 @@ const DocumentSettings = ({ datasetId, documentId }: DocumentSettingsProps) => {
</div>
{isShowSetAPIKey && (
<AccountSetting
activeTab={accountSettingTab}
onTabChangeAction={setAccountSettingTab}
onCancelAction={async () => {
activeTab="provider"
onCancel={async () => {
hideSetAPIkey()
}}
/>

View File

@@ -125,13 +125,13 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
<div className="fixed inset-0 flex items-center justify-center bg-black/[.25]">
<div className="shadows-shadow-xl relative flex w-[480px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6">
<div className="grow self-stretch text-text-primary title-2xl-semi-bold">
<div className="title-2xl-semi-bold grow self-stretch text-text-primary">
{
isEditMode ? t('editExternalAPIFormTitle', { ns: 'dataset' }) : t('createExternalAPI', { ns: 'dataset' })
}
</div>
{isEditMode && (datasetBindings?.length ?? 0) > 0 && (
<div className="flex items-center text-text-tertiary system-xs-regular">
<div className="system-xs-regular flex items-center text-text-tertiary">
{t('editExternalAPIFormWarning.front', { ns: 'dataset' })}
<span className="flex cursor-pointer items-center text-text-accent">
&nbsp;
@@ -144,12 +144,12 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
popupContent={(
<div className="p-1">
<div className="flex items-start self-stretch pb-0.5 pl-2 pr-3 pt-1">
<div className="text-text-tertiary system-xs-medium-uppercase">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
<div className="system-xs-medium-uppercase text-text-tertiary">{`${datasetBindings?.length} ${t('editExternalAPITooltipTitle', { ns: 'dataset' })}`}</div>
</div>
{datasetBindings?.map(binding => (
<div key={binding.id} className="flex items-center gap-1 self-stretch px-2 py-1">
<RiBook2Line className="h-4 w-4 text-text-secondary" />
<div className="text-text-secondary system-sm-medium">{binding.name}</div>
<div className="system-sm-medium text-text-secondary">{binding.name}</div>
</div>
))}
</div>
@@ -193,8 +193,8 @@ const AddExternalAPIModal: FC<AddExternalAPIModalProps> = ({ data, onSave, onCan
{t('externalAPIForm.save', { ns: 'dataset' })}
</Button>
</div>
<div className="flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px] border-divider-subtle
bg-background-soft px-2 py-3 text-text-tertiary system-xs-regular"
<div className="system-xs-regular flex items-center justify-center gap-1 self-stretch rounded-b-2xl border-t-[0.5px]
border-divider-subtle bg-background-soft px-2 py-3 text-text-tertiary"
>
<RiLock2Fill className="h-3 w-3 text-text-quaternary" />
{t('externalAPIForm.encrypted.front', { ns: 'dataset' })}

View File

@@ -127,14 +127,6 @@ vi.mock('@/service/use-common', () => ({
],
},
}),
useCurrentWorkspace: () => ({
data: {
trial_credits: 1000,
trial_credits_used: 100,
next_credit_reset_date: undefined,
},
isPending: false,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
@@ -199,42 +191,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('../components/indexing-section', () => ({
default: ({
currentDataset,
indexMethod,
}: {
currentDataset?: DataSet
indexMethod?: IndexingType
}) => (
<div data-testid="indexing-section">
{!!currentDataset?.doc_form && (
<>
<div>form.chunkStructure.title</div>
<a href="https://docs.dify.ai/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text">
form.chunkStructure.learnMore
</a>
</>
)}
{!!(currentDataset
&& currentDataset.doc_form !== ChunkingMode.parentChild
&& currentDataset.indexing_technique
&& indexMethod) && (
<div>form.indexMethod</div>
)}
{indexMethod === IndexingType.QUALIFIED && <div>form.embeddingModel</div>}
{currentDataset?.provider !== 'external' && indexMethod && (
<>
<div>form.retrievalSetting.title</div>
<a href="https://docs.dify.ai/use-dify/knowledge/create-knowledge/setting-indexing-methods">
form.retrievalSetting.learnMore
</a>
</>
)}
</div>
),
}))
describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -8,149 +8,75 @@ import { RETRIEVE_METHOD } from '@/types/app'
import { IndexingType } from '../../../../create/step-two'
import IndexingSection from '../indexing-section'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('@/app/components/base/divider', () => ({
default: ({ className }: { className?: string }) => (
<div data-testid="divider" className={className} />
),
// Mock app-context for child components
vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: unknown) => unknown) => {
const state = {
isCurrentWorkspaceDatasetOperator: false,
userProfile: {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
},
}
return selector(state)
},
}))
vi.mock('@/app/components/datasets/settings/chunk-structure', () => ({
default: ({ chunkStructure }: { chunkStructure: string }) => (
<div data-testid="chunk-structure" data-mode={chunkStructure}>
{chunkStructure}
</div>
),
// Mock model-provider-page hooks
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
modelList: [],
defaultModel: undefined,
currentProvider: undefined,
currentModel: undefined,
}),
useUpdateModelList: () => vi.fn(),
useUpdateModelProviders: () => vi.fn(),
useLanguage: () => 'en_US',
useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
useProviderCredentialsAndLoadBalancing: () => ({
credentials: undefined,
loadBalancing: undefined,
mutate: vi.fn(),
isLoading: false,
}),
useAnthropicBuyQuota: () => vi.fn(),
useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
useModelModalHandler: () => vi.fn(),
}))
vi.mock('@/app/components/datasets/settings/index-method', () => ({
default: ({
value,
disabled,
keywordNumber,
onChange,
onKeywordNumberChange,
}: {
value: string
disabled?: boolean
keywordNumber: number
onChange: (value: IndexingType) => void
onKeywordNumberChange: (value: number) => void
}) => (
<div
data-testid="index-method"
data-disabled={disabled ? 'true' : 'false'}
data-keyword-number={String(keywordNumber)}
data-value={value}
>
<button type="button" onClick={() => onChange(IndexingType.QUALIFIED)}>
stepTwo.qualified
</button>
<button type="button" onClick={() => onChange(IndexingType.ECONOMICAL)}>
form.indexMethodEconomy
</button>
<button type="button" onClick={() => onKeywordNumberChange(keywordNumber + 1)}>
keyword-number-increment
</button>
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({
defaultModel,
onSelect,
}: {
defaultModel?: DefaultModel
onSelect?: (value: DefaultModel) => void
}) => (
<div
data-testid="model-selector"
data-model={defaultModel?.model ?? ''}
data-provider={defaultModel?.provider ?? ''}
>
<button
type="button"
onClick={() => onSelect?.({ provider: 'cohere', model: 'embed-english-v3.0' })}
>
select-model
</button>
</div>
),
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: ({
summaryIndexSetting,
onSummaryIndexSettingChange,
}: {
summaryIndexSetting?: SummaryIndexSetting
onSummaryIndexSettingChange?: (payload: SummaryIndexSetting) => void
}) => (
<div data-testid="summary-index-setting" data-enabled={summaryIndexSetting?.enable ? 'true' : 'false'}>
<button type="button" onClick={() => onSummaryIndexSettingChange?.({ enable: true })}>
summary-enable
</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/retrieval-method-config', () => ({
default: ({
showMultiModalTip,
onChange,
value,
}: {
showMultiModalTip?: boolean
onChange: (value: RetrievalConfig) => void
value: RetrievalConfig
}) => (
<div data-testid="retrieval-method-config">
{showMultiModalTip && <span>show-multimodal-tip</span>}
<button
type="button"
onClick={() =>
onChange({
...value,
top_k: 6,
})}
>
update-retrieval
</button>
</div>
),
}))
vi.mock('@/app/components/datasets/common/economical-retrieval-method-config', () => ({
default: ({
onChange,
value,
}: {
onChange: (value: RetrievalConfig) => void
value: RetrievalConfig
}) => (
<div data-testid="economical-retrieval-method-config">
<button
type="button"
onClick={() =>
onChange({
...value,
search_method: RETRIEVE_METHOD.keywordSearch,
})}
>
update-economy-retrieval
</button>
</div>
),
// Mock provider-context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
textGenerationModelList: [],
embeddingsModelList: [],
rerankModelList: [],
agentThoughtModelList: [],
modelProviders: [],
textEmbeddingModelList: [],
speech2textModelList: [],
ttsModelList: [],
moderationModelList: [],
hasSettedApiKey: true,
plan: { type: 'free' },
enableBilling: false,
onPlanInfoChanged: vi.fn(),
isCurrentWorkspaceDatasetOperator: false,
supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
}),
}))
describe('IndexingSection', () => {
@@ -261,281 +187,315 @@ describe('IndexingSection', () => {
showMultiModalTip: false,
}
const renderComponent = (props: Partial<typeof defaultProps> = {}) => {
return render(<IndexingSection {...defaultProps} {...props} />)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render the chunk structure, index method, and retrieval sections for a standard dataset', () => {
renderComponent()
expect(screen.getByText('form.chunkStructure.title')).toBeInTheDocument()
expect(screen.getByTestId('chunk-structure')).toHaveAttribute('data-mode', ChunkingMode.text)
expect(screen.getByText('form.indexMethod')).toBeInTheDocument()
expect(screen.getByTestId('index-method')).toBeInTheDocument()
expect(screen.getByText('form.retrievalSetting.title')).toBeInTheDocument()
it('should render without crashing', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
})
it('should render the embedding model selector when the index method is high quality', () => {
renderComponent()
it('should render chunk structure section when doc_form is set', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
})
expect(screen.getByText('form.embeddingModel')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toHaveAttribute('data-model', 'text-embedding-ada-002')
it('should render index method section when conditions are met', () => {
render(<IndexingSection {...defaultProps} />)
// May match multiple elements (label and descriptions)
expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
})
it('should render embedding model section when indexMethod is high_quality', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
it('should render retrieval settings section', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
})
describe('Chunk Structure Section', () => {
it('should hide the chunk structure section when the dataset has no doc form', () => {
renderComponent({
currentDataset: {
...mockDataset,
doc_form: undefined as unknown as ChunkingMode,
},
})
it('should not render chunk structure when doc_form is not set', () => {
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
expect(screen.queryByText('form.chunkStructure.title')).not.toBeInTheDocument()
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
})
it('should render the chunk structure learn more link and description', () => {
renderComponent()
it('should render learn more link for chunk structure', () => {
render(<IndexingSection {...defaultProps} />)
const learnMoreLink = screen.getByRole('link', { name: 'form.chunkStructure.learnMore' })
const learnMoreLink = screen.getByText(/form\.chunkStructure\.learnMore/i)
expect(learnMoreLink).toBeInTheDocument()
expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('chunking-and-cleaning-text'))
expect(screen.getByText('form.chunkStructure.description')).toBeInTheDocument()
})
it('should render chunk structure description', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByText(/form\.chunkStructure\.description/i)).toBeInTheDocument()
})
})
describe('Index Method Section', () => {
it('should hide the index method section for parent-child chunking', () => {
renderComponent({
currentDataset: {
...mockDataset,
doc_form: ChunkingMode.parentChild,
},
})
it('should not render index method for parentChild chunking mode', () => {
const parentChildDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
render(<IndexingSection {...defaultProps} currentDataset={parentChildDataset} />)
expect(screen.queryByText('form.indexMethod')).not.toBeInTheDocument()
expect(screen.queryByTestId('index-method')).not.toBeInTheDocument()
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
})
it('should render both index method options', () => {
renderComponent()
it('should render high quality option', () => {
render(<IndexingSection {...defaultProps} />)
expect(screen.getByRole('button', { name: 'stepTwo.qualified' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'form.indexMethodEconomy' })).toBeInTheDocument()
expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
})
it('should call setIndexMethod when the user selects a new index method', () => {
it('should render economy option', () => {
render(<IndexingSection {...defaultProps} />)
// May match multiple elements (title and tip)
expect(screen.getAllByText(/form\.indexMethodEconomy/i).length).toBeGreaterThan(0)
})
it('should call setIndexMethod when index method changes', () => {
const setIndexMethod = vi.fn()
renderComponent({ setIndexMethod })
const { container } = render(<IndexingSection {...defaultProps} setIndexMethod={setIndexMethod} />)
fireEvent.click(screen.getByRole('button', { name: 'form.indexMethodEconomy' }))
// Find the economy option card by looking for clickable elements containing the economy text
const economyOptions = screen.getAllByText(/form\.indexMethodEconomy/i)
if (economyOptions.length > 0) {
const economyCard = economyOptions[0].closest('[class*="cursor-pointer"]')
if (economyCard) {
fireEvent.click(economyCard)
}
}
expect(setIndexMethod).toHaveBeenCalledWith(IndexingType.ECONOMICAL)
// The handler should be properly passed - verify component renders without crashing
expect(container).toBeInTheDocument()
})
it('should show an upgrade warning when moving from economy to high quality', () => {
renderComponent({
currentDataset: {
...mockDataset,
indexing_technique: IndexingType.ECONOMICAL,
},
})
it('should show upgrade warning when switching from economy to high quality', () => {
const economyDataset = { ...mockDataset, indexing_technique: IndexingType.ECONOMICAL }
render(
<IndexingSection
{...defaultProps}
currentDataset={economyDataset}
indexMethod={IndexingType.QUALIFIED}
/>,
)
expect(screen.getByText('form.upgradeHighQualityTip')).toBeInTheDocument()
expect(screen.getByText(/form\.upgradeHighQualityTip/i)).toBeInTheDocument()
})
it('should pass disabled state to the index method when embeddings are unavailable', () => {
renderComponent({
currentDataset: {
...mockDataset,
embedding_available: false,
},
})
it('should not show upgrade warning when already on high quality', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.QUALIFIED}
/>,
)
expect(screen.getByTestId('index-method')).toHaveAttribute('data-disabled', 'true')
expect(screen.queryByText(/form\.upgradeHighQualityTip/i)).not.toBeInTheDocument()
})
it('should pass the keyword number and update handler through the index method mock', () => {
const setKeywordNumber = vi.fn()
renderComponent({
keywordNumber: 15,
setKeywordNumber,
})
it('should disable index method when embedding is not available', () => {
const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutEmbedding} />)
expect(screen.getByTestId('index-method')).toHaveAttribute('data-keyword-number', '15')
fireEvent.click(screen.getByRole('button', { name: 'keyword-number-increment' }))
expect(setKeywordNumber).toHaveBeenCalledWith(16)
// Index method options should be disabled
// The exact implementation depends on the IndexMethod component
})
})
describe('Embedding Model Section', () => {
it('should hide the embedding model selector for economy indexing', () => {
renderComponent({ indexMethod: IndexingType.ECONOMICAL })
it('should render embedding model when indexMethod is high_quality', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.queryByText('form.embeddingModel')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
it('should call setEmbeddingModel when the user selects a model', () => {
it('should not render embedding model when indexMethod is economy', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
})
it('should call setEmbeddingModel when model changes', () => {
const setEmbeddingModel = vi.fn()
renderComponent({ setEmbeddingModel })
render(
<IndexingSection
{...defaultProps}
setEmbeddingModel={setEmbeddingModel}
indexMethod={IndexingType.QUALIFIED}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
expect(setEmbeddingModel).toHaveBeenCalledWith({
provider: 'cohere',
model: 'embed-english-v3.0',
})
// The embedding model selector should be rendered
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
})
})
describe('Summary Index Setting Section', () => {
it('should render the summary index setting only for high quality text chunking', () => {
renderComponent()
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
it('should render summary index setting for high quality with text chunking', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.QUALIFIED}
/>,
)
renderComponent({
indexMethod: IndexingType.ECONOMICAL,
})
expect(screen.getAllByTestId('summary-index-setting')).toHaveLength(1)
// Summary index setting should be rendered based on conditions
// The exact rendering depends on the SummaryIndexSetting component
})
it('should call handleSummaryIndexSettingChange when the summary setting changes', () => {
it('should not render summary index setting for economy indexing', () => {
render(
<IndexingSection
{...defaultProps}
indexMethod={IndexingType.ECONOMICAL}
/>,
)
// Summary index setting should not be rendered for economy
})
it('should call handleSummaryIndexSettingChange when setting changes', () => {
const handleSummaryIndexSettingChange = vi.fn()
renderComponent({ handleSummaryIndexSettingChange })
render(
<IndexingSection
{...defaultProps}
handleSummaryIndexSettingChange={handleSummaryIndexSettingChange}
indexMethod={IndexingType.QUALIFIED}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'summary-enable' }))
expect(handleSummaryIndexSettingChange).toHaveBeenCalledWith({ enable: true })
// The handler should be properly passed
})
})
describe('Retrieval Settings Section', () => {
it('should render the retrieval learn more link', () => {
renderComponent()
it('should render retrieval settings', () => {
render(<IndexingSection {...defaultProps} />)
const learnMoreLink = screen.getByRole('link', { name: 'form.retrievalSetting.learnMore' })
expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('setting-indexing-methods'))
expect(screen.getByText('form.retrievalSetting.description')).toBeInTheDocument()
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render the high-quality retrieval config and propagate changes', () => {
it('should render learn more link for retrieval settings', () => {
render(<IndexingSection {...defaultProps} />)
const learnMoreLinks = screen.getAllByText(/learnMore/i)
const retrievalLearnMore = learnMoreLinks.find(link =>
link.closest('a')?.href?.includes('setting-indexing-methods'),
)
expect(retrievalLearnMore).toBeInTheDocument()
})
it('should render RetrievalMethodConfig for high quality indexing', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
// RetrievalMethodConfig should be rendered
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should render EconomicalRetrievalMethodConfig for economy indexing', () => {
render(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
// EconomicalRetrievalMethodConfig should be rendered
expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
})
it('should call setRetrievalConfig when config changes', () => {
const setRetrievalConfig = vi.fn()
renderComponent({ setRetrievalConfig })
render(<IndexingSection {...defaultProps} setRetrievalConfig={setRetrievalConfig} />)
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'update-retrieval' }))
expect(setRetrievalConfig).toHaveBeenCalledWith({
...mockRetrievalConfig,
top_k: 6,
})
// The handler should be properly passed
})
it('should render the economical retrieval config for economy indexing', () => {
const setRetrievalConfig = vi.fn()
renderComponent({
indexMethod: IndexingType.ECONOMICAL,
setRetrievalConfig,
})
it('should pass showMultiModalTip to RetrievalMethodConfig', () => {
render(<IndexingSection {...defaultProps} showMultiModalTip={true} />)
expect(screen.getByTestId('economical-retrieval-method-config')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'update-economy-retrieval' }))
expect(setRetrievalConfig).toHaveBeenCalledWith({
...mockRetrievalConfig,
search_method: RETRIEVE_METHOD.keywordSearch,
})
// The tip should be passed to the config component
})
})
it('should pass the multimodal tip flag to the retrieval config', () => {
renderComponent({ showMultiModalTip: true })
describe('External Provider', () => {
it('should not render retrieval config for external provider', () => {
const externalDataset = { ...mockDataset, provider: 'external' }
render(<IndexingSection {...defaultProps} currentDataset={externalDataset} />)
expect(screen.getByText('show-multimodal-tip')).toBeInTheDocument()
})
it('should hide retrieval configuration for external datasets', () => {
renderComponent({
currentDataset: {
...mockDataset,
provider: 'external',
},
})
expect(screen.queryByText('form.retrievalSetting.title')).not.toBeInTheDocument()
expect(screen.queryByTestId('retrieval-method-config')).not.toBeInTheDocument()
expect(screen.queryByTestId('economical-retrieval-method-config')).not.toBeInTheDocument()
// Retrieval config should not be rendered for external provider
// This is handled by the parent component, but we verify the condition
})
})
describe('Conditional Rendering', () => {
it('should render dividers between visible sections', () => {
renderComponent()
it('should show divider between sections', () => {
const { container } = render(<IndexingSection {...defaultProps} />)
expect(screen.getAllByTestId('divider').length).toBeGreaterThan(0)
// Dividers should be present
const dividers = container.querySelectorAll('.bg-divider-subtle')
expect(dividers.length).toBeGreaterThan(0)
})
it('should hide the index method section when the dataset lacks an indexing technique', () => {
renderComponent({
currentDataset: {
...mockDataset,
indexing_technique: undefined as unknown as IndexingType,
},
indexMethod: undefined,
})
it('should not render index method when indexing_technique is not set', () => {
const datasetWithoutTechnique = { ...mockDataset, indexing_technique: undefined as unknown as IndexingType }
render(<IndexingSection {...defaultProps} currentDataset={datasetWithoutTechnique} indexMethod={undefined} />)
expect(screen.queryByText('form.indexMethod')).not.toBeInTheDocument()
expect(screen.queryByTestId('index-method')).not.toBeInTheDocument()
expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
})
})
describe('Keyword Number', () => {
it('should pass keywordNumber to IndexMethod', () => {
render(<IndexingSection {...defaultProps} keywordNumber={15} />)
// The keyword number should be displayed in the economy option description
// The exact rendering depends on the IndexMethod component
})
it('should call setKeywordNumber when keyword number changes', () => {
const setKeywordNumber = vi.fn()
render(<IndexingSection {...defaultProps} setKeywordNumber={setKeywordNumber} />)
// The handler should be properly passed
})
})
describe('Props Updates', () => {
it('should update the embedding model section when indexMethod changes', () => {
const { rerender } = renderComponent()
it('should update when indexMethod changes', () => {
const { rerender } = render(<IndexingSection {...defaultProps} indexMethod={IndexingType.QUALIFIED} />)
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
rerender(<IndexingSection {...defaultProps} indexMethod={IndexingType.ECONOMICAL} />)
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
})
it('should update the chunk structure section when currentDataset changes', () => {
const { rerender } = renderComponent()
it('should update when currentDataset changes', () => {
const { rerender } = render(<IndexingSection {...defaultProps} />)
expect(screen.getByTestId('chunk-structure')).toBeInTheDocument()
expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
rerender(
<IndexingSection
{...defaultProps}
currentDataset={{
...mockDataset,
doc_form: undefined as unknown as ChunkingMode,
}}
/>,
)
const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
rerender(<IndexingSection {...defaultProps} currentDataset={datasetWithoutDocForm} />)
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
})
})
describe('Undefined Dataset', () => {
it('should render safely when currentDataset is undefined', () => {
renderComponent({ currentDataset: undefined })
it('should handle undefined currentDataset gracefully', () => {
render(<IndexingSection {...defaultProps} currentDataset={undefined} />)
expect(screen.queryByTestId('chunk-structure')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
expect(screen.getByTestId('retrieval-method-config')).toBeInTheDocument()
// Should not crash and should handle undefined gracefully
// Most sections should not render without a dataset
})
})
})

View File

@@ -63,7 +63,7 @@ const SummaryIndexSetting = ({
return (
<div>
<div className="flex h-6 items-center justify-between">
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
<Tooltip
triggerClassName="ml-1 h-4 w-4 shrink-0"
@@ -80,7 +80,7 @@ const SummaryIndexSetting = ({
{
summaryIndexSetting?.enable && (
<div>
<div className="mb-1.5 mt-2 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
<div className="system-xs-medium-uppercase mb-1.5 mt-2 flex h-6 items-center text-text-tertiary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
@@ -90,7 +90,7 @@ const SummaryIndexSetting = ({
readonly={readonly}
showDeprecatedWarnIcon
/>
<div className="mt-3 flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
<div className="system-xs-medium-uppercase mt-3 flex h-6 items-center text-text-tertiary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea
@@ -111,12 +111,12 @@ const SummaryIndexSetting = ({
<div className="space-y-4">
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="text-text-secondary system-sm-semibold">
<div className="system-sm-semibold text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
<div className="py-1.5">
<div className="flex items-center text-text-secondary system-sm-semibold">
<div className="system-sm-semibold flex items-center text-text-secondary">
<Switch
className="mr-2"
value={summaryIndexSetting?.enable ?? false}
@@ -127,7 +127,7 @@ const SummaryIndexSetting = ({
summaryIndexSetting?.enable ? t('list.status.enabled', { ns: 'datasetDocuments' }) : t('list.status.disabled', { ns: 'datasetDocuments' })
}
</div>
<div className="mt-2 text-text-tertiary system-sm-regular">
<div className="system-sm-regular mt-2 text-text-tertiary">
{
summaryIndexSetting?.enable && t('form.summaryAutoGenTip', { ns: 'datasetSettings' })
}
@@ -142,7 +142,7 @@ const SummaryIndexSetting = ({
<>
<div className="flex gap-x-1">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="text-text-tertiary system-sm-medium">
<div className="system-sm-medium text-text-tertiary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
</div>
@@ -159,7 +159,7 @@ const SummaryIndexSetting = ({
</div>
<div className="flex">
<div className="flex h-7 w-[180px] shrink-0 items-center pt-1">
<div className="text-text-tertiary system-sm-medium">
<div className="system-sm-medium text-text-tertiary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
</div>
@@ -188,7 +188,7 @@ const SummaryIndexSetting = ({
onChange={handleSummaryIndexEnableChange}
size="md"
/>
<div className="text-text-secondary system-sm-semibold">
<div className="system-sm-semibold text-text-secondary">
{t('form.summaryAutoGen', { ns: 'datasetSettings' })}
</div>
</div>
@@ -196,7 +196,7 @@ const SummaryIndexSetting = ({
summaryIndexSetting?.enable && (
<>
<div>
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
{t('form.summaryModel', { ns: 'datasetSettings' })}
</div>
<ModelSelector
@@ -209,7 +209,7 @@ const SummaryIndexSetting = ({
/>
</div>
<div>
<div className="mb-1.5 flex h-6 items-center text-text-secondary system-sm-medium">
<div className="system-sm-medium mb-1.5 flex h-6 items-center text-text-secondary">
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
</div>
<Textarea

View File

@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: () => ({
data: [{
provider: 'openai',
models: [{ model: 'gpt-4o-mini' }],
}],
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
<div data-testid="evaluation-model-selector">
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
</div>
),
}))
describe('Evaluation', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should search, add metrics, and create a batch history record', async () => {
vi.useFakeTimers()
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
expect(screen.getByTestId('evaluation-metric-loading')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'does-not-exist' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchPlaceholder'), {
target: { value: 'faith' },
})
await act(async () => {
vi.advanceTimersByTime(200)
})
fireEvent.click(screen.getByRole('button', { name: /Faithfulness/i }))
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
await act(async () => {
vi.advanceTimersByTime(1300)
})
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
vi.useRealTimers()
})
it('should render time placeholders and hide the value row for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
let groupId = ''
let itemId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
})
let rerender: ReturnType<typeof render>['rerender']
act(() => {
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
})
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,96 @@
import { getEvaluationMockConfig } from '../mock'
import {
getAllowedOperators,
isCustomMetricConfigured,
requiresConditionValue,
useEvaluationStore,
} from '../store'
describe('evaluation store', () => {
beforeEach(() => {
useEvaluationStore.setState({ resources: {} })
})
it('should configure a custom metric mapping to a valid state', () => {
const resourceType = 'workflow'
const resourceId = 'app-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
expect(initialMetric).toBeDefined()
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
sourceFieldId: config.fieldOptions[0].id,
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
})
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
})
it('should add and remove builtin metrics', () => {
const resourceType = 'workflow'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
expect(addedMetric).toBeDefined()
store.removeMetric(resourceType, resourceId, addedMetric!.id)
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'pipeline'
const resourceId = 'dataset-1'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
})
it('should support time fields and clear values for empty operators', () => {
const resourceType = 'workflow'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
const timeField = config.fieldOptions.find(field => field.type === 'time')!
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
MetricOption,
} from './types'
const judgeModels = [
{
id: 'gpt-4.1-mini',
label: 'GPT-4.1 mini',
provider: 'OpenAI',
},
{
id: 'claude-3-7-sonnet',
label: 'Claude 3.7 Sonnet',
provider: 'Anthropic',
},
{
id: 'gemini-2.0-flash',
label: 'Gemini 2.0 Flash',
provider: 'Google',
},
]
const builtinMetrics: MetricOption[] = [
{
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
group: 'quality',
badges: ['LLM', 'Built-in'],
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
group: 'quality',
badges: ['LLM', 'Retrieval'],
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
group: 'quality',
badges: ['LLM'],
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
group: 'operations',
badges: ['System'],
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
group: 'operations',
badges: ['System'],
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
group: 'operations',
badges: ['Workflow'],
},
]
const workflowOptions = [
{
id: 'workflow-precision-review',
label: 'Precision Review Workflow',
description: 'Custom evaluator for nuanced quality review.',
targetVariables: [
{ id: 'query', label: 'query' },
{ id: 'answer', label: 'answer' },
{ id: 'reference', label: 'reference' },
],
},
{
id: 'workflow-risk-review',
label: 'Risk Review Workflow',
description: 'Custom evaluator for policy and escalation checks.',
targetVariables: [
{ id: 'input', label: 'input' },
{ id: 'output', label: 'output' },
],
},
]
const workflowFields: EvaluationFieldOption[] = [
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
]
const pipelineFields: EvaluationFieldOption[] = [
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
]
const snippetFields: EvaluationFieldOption[] = [
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'time')
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'pipeline') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: pipelineFields,
templateFileName: 'pipeline-evaluation-template.csv',
batchRequirements: [
'Include one row per retrieval scenario.',
'Provide the expected source or target chunk for each case.',
'Keep numeric metrics in plain number format.',
],
historySummaryLabel: 'Pipeline evaluation batch',
}
}
if (resourceType === 'snippet') {
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: snippetFields,
templateFileName: 'snippet-evaluation-template.csv',
batchRequirements: [
'Include one row per snippet execution case.',
'Provide the expected final content or acceptance rule.',
'Keep optional fields empty when not used.',
],
historySummaryLabel: 'Snippet evaluation batch',
}
}
return {
judgeModels,
builtinMetrics,
workflowOptions,
fieldOptions: workflowFields,
templateFileName: 'workflow-evaluation-template.csv',
batchRequirements: [
'Include one row per workflow test case.',
'Provide both user input and expected answer when available.',
'Keep boolean columns as true or false.',
],
historySummaryLabel: 'Workflow evaluation batch',
}
}

View File

@@ -0,0 +1,635 @@
import type {
BatchTestRecord,
ComparisonOperator,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
} from './types'
import { create } from 'zustand'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string) => void
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
mappingId: string,
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
) => void
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
}
const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const initialResourceCache: Record<string, EvaluationResourceState> = {}
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
const getConditionValue = (
field: EvaluationFieldOption | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
) => {
if (!field || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' ? previousValue : null
}
const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
}
}
const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
const config = getEvaluationMockConfig(resourceType)
const defaultMetric = config.builtinMetrics[0]
return {
judgeModelId: null,
metrics: defaultMetric
? [{
id: createId('metric'),
optionId: defaultMetric.id,
kind: 'builtin',
label: defaultMetric.label,
description: defaultMetric.description,
badges: defaultMetric.badges,
}]
: [],
conditions: [{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
}],
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
}
}
const withResourceState = (
resources: EvaluationStore['resources'],
resourceType: EvaluationResourceType,
resourceId: string,
) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return {
resourceKey,
resource: resources[resourceKey] ?? buildInitialState(resourceType),
}
}
const updateMetric = (
metrics: EvaluationMetric[],
metricId: string,
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
if (metric.kind !== 'custom-workflow')
return true
if (!metric.customConfig?.workflowId)
return false
return metric.customConfig.mappings.length > 0
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
}
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
resources: {},
ensureResource: (resourceType, resourceId) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
if (get().resources[resourceKey])
return
set(state => ({
resources: {
...state.resources,
[resourceKey]: buildInitialState(resourceType),
},
}))
},
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
judgeModelId,
},
},
}
})
},
addBuiltinMetric: (resourceType, resourceId, optionId) => {
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
if (!option)
return
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
if (resource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin'))
return state
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: option.id,
kind: 'builtin',
label: option.label,
description: option.description,
badges: option.badges,
},
],
},
},
}
})
},
addCustomMetric: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: [
...resource.metrics,
{
id: createId('metric'),
optionId: createId('custom'),
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
badges: ['Workflow'],
customConfig: {
workflowId: null,
mappings: [{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
}],
},
},
],
},
},
}
})
},
removeMetric: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
},
},
}
})
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
workflowId,
mappings: metric.customConfig.mappings.map(mapping => ({
...mapping,
targetVariableId: null,
})),
}
: metric.customConfig,
})),
},
},
}
})
},
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: [
...metric.customConfig.mappings,
{
id: createId('mapping'),
sourceFieldId: null,
targetVariableId: null,
},
],
}
: metric.customConfig,
})),
},
},
}
})
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
}
: metric.customConfig,
})),
},
},
}
})
},
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
}
: metric.customConfig,
})),
},
},
}
})
},
addConditionGroup: (resourceType, resourceId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: [
...resource.conditions,
{
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
},
],
},
},
}
})
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
},
},
}
})
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
logicalOperator,
})),
},
},
}
})
},
addConditionItem: (resourceType, resourceId, groupId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [
...group.items,
buildConditionItem(resourceType),
],
})),
},
},
}
})
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
},
},
}
})
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
return {
...item,
fieldId,
operator: field ? getDefaultOperator(field.type) : item.operator,
value: getConditionValue(field, field ? getDefaultOperator(field.type) : item.operator),
}
}),
})),
},
},
}
})
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
const field = fieldOptions.find(option => option.id === item.fieldId)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
}
}),
})),
},
},
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
},
},
}
})
},
setBatchTab: (resourceType, resourceId, tab) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: tab,
},
},
}
})
},
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
uploadedFileName,
},
},
}
})
},
runBatchTest: (resourceType, resourceId) => {
const config = getEvaluationMockConfig(resourceType)
const recordId = createId('batch')
const nextRecord: BatchTestRecord = {
id: recordId,
fileName: get().resources[buildResourceKey(resourceType, resourceId)]?.uploadedFileName ?? config.templateFileName,
status: 'running',
startedAt: new Date().toLocaleTimeString(),
summary: config.historySummaryLabel,
}
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
activeBatchTab: 'history',
batchRecords: [nextRecord, ...resource.batchRecords],
},
},
}
})
window.setTimeout(() => {
set((state) => {
const { resource, resourceKey } = withResourceState(state.resources, resourceType, resourceId)
return {
resources: {
...state.resources,
[resourceKey]: {
...resource,
batchRecords: resource.batchRecords.map(record => record.id === recordId
? {
...record,
status: resource.metrics.length > 1 ? 'success' : 'failed',
}
: record),
},
},
}
})
}, 1200)
},
}))
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
const resourceKey = buildResourceKey(resourceType, resourceId)
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
if (!field)
return ['contains'] as ComparisonOperator[]
return getComparisonOperators(field.type)
}

View File

@@ -0,0 +1,117 @@
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
export type MetricKind = 'builtin' | 'custom-workflow'
export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'before'
| 'after'
export type JudgeModelOption = {
id: string
label: string
provider: string
}
export type MetricOption = {
id: string
label: string
description: string
group: string
badges: string[]
}
export type EvaluationWorkflowOption = {
id: string
label: string
description: string
targetVariables: Array<{
id: string
label: string
}>
}
export type EvaluationFieldOption = {
id: string
label: string
group: string
type: FieldType
options?: Array<{
value: string
label: string
}>
}
export type CustomMetricMapping = {
id: string
sourceFieldId: string | null
targetVariableId: string | null
}
export type CustomMetricConfig = {
workflowId: string | null
mappings: CustomMetricMapping[]
}
export type EvaluationMetric = {
id: string
optionId: string
kind: MetricKind
label: string
description: string
badges: string[]
customConfig?: CustomMetricConfig
}
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
}
export type JudgmentConditionGroup = {
id: string
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
}
export type BatchTestRecord = {
id: string
fileName: string
status: 'running' | 'success' | 'failed'
startedAt: string
summary: string
}
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]
}
export type EvaluationMockConfig = {
judgeModels: JudgeModelOption[]
builtinMetrics: MetricOption[]
workflowOptions: EvaluationWorkflowOption[]
fieldOptions: EvaluationFieldOption[]
templateFileName: string
batchRequirements: string[]
historySummaryLabel: string
}

View File

@@ -46,7 +46,7 @@ const WorkplaceSelector = () => {
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="min-w-0 max-w-[149px] truncate text-text-secondary system-sm-medium max-[800px]:hidden">{currentWorkspace?.name}</div>
<div className="system-sm-medium min-w-0 max-w-[149px] truncate text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
</div>
</MenuButton>
@@ -68,9 +68,9 @@ const WorkplaceSelector = () => {
`,
)}
>
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg ">
<div className="flex items-start self-stretch px-3 pb-0.5 pt-1">
<span className="flex-1 text-text-tertiary system-xs-medium-uppercase">{t('userProfile.workspace', { ns: 'common' })}</span>
<span className="system-xs-medium-uppercase flex-1 text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
</div>
{
workspaces.map(workspace => (
@@ -78,7 +78,7 @@ const WorkplaceSelector = () => {
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle font-semibold uppercase leading-6 text-shadow-shadow-1 opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary system-md-regular">{workspace.name}</div>
<div className="system-md-regular line-clamp-1 grow cursor-pointer overflow-hidden text-ellipsis text-text-secondary">{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} />
</div>
))

View File

@@ -1,16 +1,15 @@
import type { AccountSettingTab } from '../constants'
import type { ComponentProps, ReactNode } from 'react'
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
const mockResetModelProviderListExpanded = vi.fn()
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
@@ -47,27 +46,65 @@ vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
vi.mock('@/app/components/billing/billing-page', () => ({
default: () => <div data-testid="billing-page">Billing Page</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
vi.mock('@/app/components/custom/custom-page', () => ({
default: () => <div data-testid="custom-page">Custom Page</div>,
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({
default: () => <div data-testid="api-based-extension-page">API Based Extension Page</div>,
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
default: () => <div data-testid="data-source-page">Data Source Page</div>,
}))
vi.mock('@/app/components/header/account-setting/language-page', () => ({
default: () => <div data-testid="language-page">Language Page</div>,
}))
vi.mock('@/app/components/header/account-setting/members-page', () => ({
default: () => <div data-testid="members-page">Members Page</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
default: ({ searchText }: { searchText: string }) => (
<div data-testid="provider-page">
{`provider-search:${searchText}`}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/menu-dialog', () => ({
default: function MockMenuDialog({
children,
onClose,
show,
}: {
children: ReactNode
onClose: () => void
show?: boolean
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
if (!show)
return null
return <div role="dialog">{children}</div>
},
}))
const baseAppContextValue: AppContextValue = {
@@ -114,37 +151,30 @@ const baseAppContextValue: AppContextValue = {
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
const renderAccountSetting = (props?: {
initialTab?: AccountSettingTab
onCancel?: () => void
onTabChange?: (tab: AccountSettingTab) => void
}) => {
const {
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
onCancel = mockOnCancel,
onTabChange = mockOnTabChange,
} = props ?? {}
const StatefulAccountSetting = () => {
const [activeTab, setActiveTab] = useState<AccountSettingTab>(initialTab)
return (
<AccountSetting
onCancelAction={onCancel}
activeTab={activeTab}
onTabChangeAction={(tab) => {
setActiveTab(tab)
onTabChange(tab)
}}
/>
)
const renderAccountSetting = (props: Partial<ComponentProps<typeof AccountSetting>> = {}) => {
const queryClient = new QueryClient()
const mergedProps: ComponentProps<typeof AccountSetting> = {
onCancel: mockOnCancel,
...props,
}
return render(
<QueryClientProvider client={new QueryClient()}>
<StatefulAccountSetting />
const view = render(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} />
</QueryClientProvider>,
)
return {
...view,
rerenderAccountSetting(nextProps: Partial<ComponentProps<typeof AccountSetting>>) {
view.rerender(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} {...nextProps} />
</QueryClientProvider>,
)
},
}
}
beforeEach(() => {
@@ -160,171 +190,155 @@ describe('AccountSetting', () => {
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
// Act
renderAccountSetting()
// Assert
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByText('custom.custom')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
expect(screen.getByTitle('common.settings.provider')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.members')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.billing')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByTitle('custom.custom')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
expect(screen.getByTestId('members-page')).toBeInTheDocument()
})
it('should respect the initial tab', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
it('should respect the activeTab prop', () => {
renderAccountSetting({ activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
// Assert
// Check that the active item title is Data Source
const titles = screen.getAllByText('common.settings.dataSource')
// One in sidebar, one in header.
expect(titles.length).toBeGreaterThan(1)
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
})
it('should sync the rendered page when activeTab changes', async () => {
const { rerenderAccountSetting } = renderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
rerenderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.CUSTOM,
})
await waitFor(() => {
expect(screen.getByTestId('custom-page')).toBeInTheDocument()
})
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
// Act
renderAccountSetting()
// Assert
// On mobile, the labels should not be rendered as per the implementation
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should filter items for dataset operator', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
})
// Act
renderAccountSetting()
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByText('common.settings.language')).toBeInTheDocument()
expect(screen.queryByTitle('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByTitle('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
})
it('should hide billing and custom tabs when disabled', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: false,
enableReplaceWebAppLogo: false,
})
// Act
renderAccountSetting()
// Assert
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
expect(screen.queryByTitle('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByTitle('custom.custom')).not.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should change active tab when clicking on menu item', () => {
// Arrange
it('should change active tab when clicking on a menu item', async () => {
const user = userEvent.setup()
renderAccountSetting({ onTabChange: mockOnTabChange })
// Act
fireEvent.click(screen.getByText('common.settings.provider'))
await user.click(screen.getByTitle('common.settings.provider'))
// Assert
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
// Check for content from ModelProviderPage
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByTestId('provider-page')).toBeInTheDocument()
})
it('should navigate through various tabs and show correct details', () => {
// Act & Assert
it.each([
['common.settings.billing', 'billing-page'],
['common.settings.dataSource', 'data-source-page'],
['common.settings.apiBasedExtension', 'api-based-extension-page'],
['custom.custom', 'custom-page'],
['common.settings.language', 'language-page'],
['common.settings.members', 'members-page'],
])('should render the "%s" page when its sidebar item is selected', async (menuTitle, pageTestId) => {
const user = userEvent.setup()
renderAccountSetting()
// Billing
fireEvent.click(screen.getByText('common.settings.billing'))
// Billing Page renders plansCommon.plan if data is loaded, or generic text.
// Checking for title in header which is always there
expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
await user.click(screen.getByTitle(menuTitle))
// Data Source
fireEvent.click(screen.getByText('common.settings.dataSource'))
expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
// API Based Extension
fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
// Custom
fireEvent.click(screen.getByText('custom.custom'))
// Custom Page uses 'custom.custom' key as well.
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
// Language
fireEvent.click(screen.getAllByText('common.settings.language')[0])
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
// Members
fireEvent.click(screen.getAllByText('common.settings.members')[0])
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
expect(screen.getByTestId(pageTestId)).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onCancel when clicking close button', () => {
// Act
renderAccountSetting()
const closeIcon = document.querySelector('.i-ri-close-line')
const closeButton = closeIcon?.closest('button')
expect(closeButton).not.toBeNull()
fireEvent.click(closeButton!)
it('should call onCancel when clicking the close button', async () => {
const user = userEvent.setup()
renderAccountSetting()
const closeControls = screen.getByText('ESC').parentElement
expect(closeControls).not.toBeNull()
if (!closeControls)
throw new Error('Close controls are missing')
await user.click(within(closeControls).getByRole('button'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when pressing Escape key', () => {
// Act
renderAccountSetting()
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update search value in provider tab', () => {
// Arrange
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
it('should update search value in the provider tab', async () => {
const user = userEvent.setup()
renderAccountSetting()
await user.click(screen.getByTitle('common.settings.provider'))
// Act
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test-search' } })
await user.type(input, 'test-search')
// Assert
expect(input).toHaveValue('test-search')
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByText('provider-search:test-search')).toBeInTheDocument()
})
it('should handle scroll event in panel', () => {
// Act
renderAccountSetting()
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
// Assert
expect(scrollContainer).toBeInTheDocument()
if (scrollContainer) {
// Scroll down
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
expect(scrollContainer).toHaveClass('overflow-y-auto')
// Scroll back up
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
}
})

View File

@@ -40,7 +40,8 @@ describe('MenuDialog', () => {
)
// Assert
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
const panel = screen.getByRole('dialog').querySelector('.custom-class')
expect(panel).toBeInTheDocument()
})
})

View File

@@ -1,6 +1,6 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page'
@@ -20,16 +20,15 @@ import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
const iconClassName = `
w-5 h-5 mr-2
`
type IAccountSettingProps = {
onCancelAction: () => void
activeTab: AccountSettingTab
onTabChangeAction: (tab: AccountSettingTab) => void
onCancel: () => void
activeTab?: AccountSettingTab
onTabChange?: (tab: AccountSettingTab) => void
}
type GroupItem = {
@@ -41,12 +40,14 @@ type GroupItem = {
}
export default function AccountSetting({
onCancelAction,
activeTab,
onTabChangeAction,
onCancel,
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
onTabChange,
}: IAccountSettingProps) {
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const activeMenu = activeTab
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
useEffect(() => {
setActiveMenu(activeTab)
}, [activeTab])
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
@@ -147,22 +148,10 @@ export default function AccountSetting({
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback((tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER)
resetModelProviderListExpanded()
onTabChangeAction(tab)
}, [onTabChangeAction, resetModelProviderListExpanded])
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancelAction()
}, [onCancelAction, resetModelProviderListExpanded])
return (
<MenuDialog
show
onClose={handleClose}
onClose={onCancel}
>
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
@@ -177,22 +166,21 @@ export default function AccountSetting({
<div>
{
menuItem.items.map(item => (
<button
type="button"
<div
key={item.key}
className={cn(
'mb-0.5 flex h-[37px] w-full items-center rounded-lg p-1 pl-3 text-left text-sm',
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
activeMenu === item.key ? 'bg-state-base-active text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-medium',
)}
aria-label={item.name}
title={item.name}
onClick={() => {
handleTabChange(item.key)
setActiveMenu(item.key)
onTabChange?.(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
{!isMobile && <div className="truncate">{item.name}</div>}
</button>
</div>
))
}
</div>
@@ -207,8 +195,7 @@ export default function AccountSetting({
variant="tertiary"
size="large"
className="px-2"
aria-label={t('operation.close', { ns: 'common' })}
onClick={handleClose}
onClick={onCancel}
>
<span className="i-ri-close-line h-5 w-5" />
</Button>

View File

@@ -1,6 +1,6 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
@@ -40,24 +40,35 @@ describe('EditWorkspaceModal', () => {
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
it('should render on the base/ui overlay layer', async () => {
renderModal()
expect(await screen.findByRole('dialog')).toHaveClass('z-[1002]')
})
it('should let user edit workspace name', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
})
it('should reset name to current workspace name when cleared', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
// Click the clear button (Input component clear button)
const clearBtn = screen.getByTestId('input-clear')
await user.click(clearBtn)
expect(input).toHaveValue('Test Workspace')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
@@ -66,10 +77,10 @@ describe('EditWorkspaceModal', () => {
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByTestId('edit-workspace-save'))
await user.click(screen.getByTestId('edit-workspace-confirm'))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
@@ -78,8 +89,6 @@ describe('EditWorkspaceModal', () => {
})
expect(mockAssign).toHaveBeenCalledWith('http://localhost')
})
expect(mockOnCancel).not.toHaveBeenCalled()
})
it('should show error toast when update fails', async () => {
@@ -89,10 +98,7 @@ describe('EditWorkspaceModal', () => {
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'Broken Workspace')
await user.click(screen.getByTestId('edit-workspace-save'))
await user.click(screen.getByTestId('edit-workspace-confirm'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
@@ -101,40 +107,6 @@ describe('EditWorkspaceModal', () => {
})
})
it('should disable save button when there are no changes', async () => {
renderModal()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
})
it('should disable save button and show error when the name is empty', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
})
it('should not submit when the form is submitted while save is disabled', async () => {
renderModal()
const saveButton = screen.getByTestId('edit-workspace-save')
const form = saveButton.closest('form')
expect(saveButton).toBeDisabled()
expect(form).not.toBeNull()
fireEvent.submit(form!)
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
@@ -143,7 +115,7 @@ describe('EditWorkspaceModal', () => {
renderModal()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
expect(screen.getByTestId('edit-workspace-confirm')).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {
@@ -161,14 +133,4 @@ describe('EditWorkspaceModal', () => {
await user.click(screen.getByTestId('edit-workspace-cancel'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', async () => {
renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@@ -1,57 +0,0 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context'
import EditWorkspaceModal from './index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
describe('EditWorkspaceModal dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as never)
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<EditWorkspaceModal onCancel={onCancel} />
</ToastContext.Provider>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,19 +1,20 @@
'use client'
import { useId, useMemo, useState } from 'react'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { useAppContext } from '@/context/app-context'
import { updateWorkspaceInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
type IEditWorkspaceModalProps = {
onCancel: () => void
}
const EditWorkspaceModal = ({
onCancel,
}: IEditWorkspaceModalProps) => {
@@ -21,33 +22,13 @@ const EditWorkspaceModal = ({
const { notify } = useContext(ToastContext)
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const [name, setName] = useState<string>(currentWorkspace.name)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputId = useId()
const errorId = useId()
const normalizedName = name.trim()
const hasChanges = normalizedName !== currentWorkspace.name
const hasError = normalizedName.length === 0
const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting
const nameErrorMessage = useMemo(() => {
if (!hasError)
return ''
return t('errorMsg.fieldRequired', {
ns: 'common',
field: t('account.workspaceName', { ns: 'common' }),
})
}, [hasError, t])
const changeWorkspaceInfo = async () => {
if (isSaveDisabled)
return
setIsSubmitting(true)
const changeWorkspaceInfo = async (name: string) => {
try {
await updateWorkspaceInfo({
url: '/workspaces/info',
body: {
name: normalizedName,
name,
},
})
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
@@ -56,74 +37,33 @@ const EditWorkspaceModal = ({
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
finally {
setIsSubmitting(false)
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="overflow-visible"
>
<DialogCloseButton data-testid="edit-workspace-close" />
<form
className="flex flex-col"
onSubmit={(e) => {
e.preventDefault()
void changeWorkspaceInfo()
}}
>
<div className="mb-4 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">
{t('account.editWorkspaceInfo', { ns: 'common' })}
</DialogTitle>
</div>
<div className="space-y-2">
<label htmlFor={inputId} className="block text-sm font-medium text-text-primary">
{t('account.workspaceName', { ns: 'common' })}
</label>
<Input
id={inputId}
autoFocus
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
className={cn(
hasError && 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
)}
/>
<div className="min-h-6">
{hasError && (
<p
id={errorId}
data-testid="edit-workspace-error"
className="text-text-destructive system-xs-regular"
role="alert"
>
{nameErrorMessage}
</p>
)}
</div>
</div>
<div className={cn(s.wrap)}>
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">{t('account.editWorkspaceInfo', { ns: 'common' })}</div>
<div className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary" data-testid="edit-workspace-close" onClick={onCancel} />
</div>
<div>
<div className="mb-2 text-sm font-medium text-text-primary">{t('account.workspaceName', { ns: 'common' })}</div>
<Input
className="mb-2"
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
onClear={() => {
setName(currentWorkspace.name)
}}
showClearIcon
/>
<div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4">
<Button
size="large"
type="button"
data-testid="edit-workspace-cancel"
onClick={onCancel}
>
@@ -131,22 +71,21 @@ const EditWorkspaceModal = ({
</Button>
<Button
size="large"
type="submit"
variant="primary"
data-testid="edit-workspace-save"
disabled={isSaveDisabled}
loading={isSubmitting}
data-testid="edit-workspace-confirm"
onClick={() => {
changeWorkspaceInfo(name)
onCancel()
}}
disabled={!isCurrentWorkspaceOwner}
>
{t(
isSubmitting ? 'operation.saving' : 'operation.save',
{ ns: 'common' },
)}
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</Modal>
</div>
)
}
export default EditWorkspaceModal

View File

@@ -3,7 +3,7 @@ import type { InvitationResult } from '@/models/common'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Tooltip from '@/app/components/base/tooltip'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
@@ -59,25 +59,20 @@ const MembersPage = () => {
<span>{currentWorkspace?.name}</span>
{isCurrentWorkspaceOwner && (
<span>
<Tooltip>
<TooltipTrigger
render={(
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setEditWorkspaceModalVisible(true)
}}
>
<div
data-testid="edit-workspace-pencil"
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
/>
</div>
)}
/>
<TooltipContent>
{t('account.editWorkspaceInfo', { ns: 'common' })}
</TooltipContent>
<Tooltip
popupContent={t('account.editWorkspaceInfo', { ns: 'common' })}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setEditWorkspaceModalVisible(true)
}}
>
<div
data-testid="edit-workspace-pencil"
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
/>
</div>
</Tooltip>
</span>
)}

View File

@@ -97,7 +97,7 @@ const Operation = ({
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary system-sm-regular hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
@@ -114,8 +114,8 @@ const Operation = ({
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
</div>
</div>
))
@@ -125,8 +125,8 @@ const Operation = ({
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
</div>
</div>
</div>

View File

@@ -1,42 +0,0 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import MenuDialog from './menu-dialog'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
describe('MenuDialog dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should only call onClose when the dialog requests closing', () => {
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

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