Compare commits

...

15 Commits

Author SHA1 Message Date
L1nSn0w
105a5ddb9e feat(workspace): implement partial fallback logic for tenant plan retrieval based on billing features
- Added a new function to resolve tenant plans from billing feature flags.
- Updated TenantListApi to utilize the new function for plan assignment when tenant plans are missing.
- Enhanced unit tests to cover scenarios for both enabled and disabled billing features.
2026-03-20 14:30:32 +08:00
L1nSn0w
467351b1fb refactor(workspace): simplify tenant plan assignment logic to use CloudPlan directly 2026-03-20 14:30:32 +08:00
L1nSn0w
5aa68b0a55 refactor(workspace): enhance tenant plan retrieval logic to handle missing plans 2026-03-20 14:30:32 +08:00
L1nSn0w
c46dfc8a0d refactor(workspace): update tenant plan retrieval to use string values from CloudPlan 2026-03-20 14:30:32 +08:00
L1nSn0w
9150a3df21 refactor(workspace): improve bulk billing error handling and logging in TenantListApi 2026-03-20 14:30:32 +08:00
L1nSn0w
5870e82a30 refactor(workspace): update tenant plans type annotation to use SubscriptionPlan 2026-03-20 14:30:32 +08:00
L1nSn0w
8778b77ee6 fix(workspace): update tenant plan retrieval logic to default to SANDBOX when billing is disabled 2026-03-20 14:30:32 +08:00
L1nSn0w
6ceb15c098 feat(workspace): integrate BillingService for SaaS tenant plan retrieval
- Added BillingService to fetch tenant plans in bulk for SaaS configurations.
- Updated TenantListApi to handle plan assignment based on billing status.
- Enhanced unit tests to validate new billing logic and fallback mechanisms.
2026-03-20 14:30:32 +08:00
L1nSn0w
238c15ea8f feat(workspace): enhance TenantListApi to handle enterprise and billing configurations
- Introduced dify_config to manage enterprise and billing settings.
- Updated tenant plan retrieval logic based on enterprise and billing status.
- Modified unit tests to cover new configurations and ensure correct plan assignment.
2026-03-20 14:30:32 +08:00
yyh
4d538c3727 refactor(web): migrate tools/MCP/external-knowledge toast usage to UI toast and add i18n (#33797) 2026-03-20 14:29:40 +08:00
github-actions[bot]
f35a4e5249 chore(i18n): sync translations with en-US (#33796)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-03-20 14:19:37 +08:00
yyh
978ebbf9ea refactor: migrate high-risk overlay follow-up selectors (#33795)
Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-20 14:12:35 +08:00
kurokobo
d6e247849f fix: add max_retries=0 for executor (#33688)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-20 14:07:32 +08:00
yyh
aa71784627 refactor(toast): migrate dataset-pipeline to new ui toast API and extract i18n (#33794) 2026-03-20 12:17:27 +08:00
yyh
a0135e9e38 refactor: migrate tag filter overlay and remove dead z-index override prop (#33791) 2026-03-20 11:15:22 +08:00
67 changed files with 946 additions and 627 deletions

View File

@@ -7,6 +7,7 @@ from sqlalchemy import select
from werkzeug.exceptions import Unauthorized
import services
from configs import dify_config
from controllers.common.errors import (
FilenameNotExistsError,
FileTooLargeError,
@@ -29,6 +30,7 @@ from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required
from models.account import Tenant, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.file_service import FileService
@@ -38,6 +40,14 @@ logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
def _plan_from_billing_features(tenant_id: str) -> str:
"""Resolve plan from billing feature flags (SaaS partial-fallback path)."""
features = FeatureService.get_features(tenant_id)
if features.billing.enabled:
return features.billing.subscription.plan or CloudPlan.SANDBOX
return CloudPlan.SANDBOX
class WorkspaceListQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=20, ge=1, le=100)
@@ -108,9 +118,31 @@ class TenantListApi(Resource):
current_user, current_tenant_id = current_account_with_tenant()
tenants = TenantService.get_join_tenants(current_user)
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
tenant_plans: dict[str, SubscriptionPlan] = {}
use_legacy_feature_path = not is_enterprise_only and not is_saas
if is_saas:
tenant_ids = [tenant.id for tenant in tenants]
if tenant_ids:
tenant_plans = BillingService.get_plan_bulk(tenant_ids)
# If bulk fetch returned empty for non-empty input, fall back to legacy path
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
use_legacy_feature_path = True
for tenant in tenants:
features = FeatureService.get_features(tenant.id)
plan: str = CloudPlan.SANDBOX
if is_saas and not use_legacy_feature_path:
tenant_plan = tenant_plans.get(tenant.id)
if tenant_plan:
plan = tenant_plan["plan"] or CloudPlan.SANDBOX
else:
plan = _plan_from_billing_features(tenant.id)
elif not is_enterprise_only:
features = FeatureService.get_features(tenant.id)
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
# Create a dictionary with tenant attributes
tenant_dict = {
@@ -118,7 +150,7 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"plan": features.billing.subscription.plan if features.billing.enabled else CloudPlan.SANDBOX,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}

View File

@@ -101,6 +101,9 @@ class HttpRequestNode(Node[HttpRequestNodeData]):
timeout=self._get_request_timeout(self.node_data),
variable_pool=self.graph_runtime_state.variable_pool,
http_request_config=self._http_request_config,
# Must be 0 to disable executor-level retries, as the graph engine handles them.
# This is critical to prevent nested retries.
max_retries=0,
ssl_verify=self.node_data.ssl_verify,
http_client=self._http_client,
file_manager=self._file_manager,

View File

@@ -36,7 +36,162 @@ def unwrap(func):
class TestTenantListApi:
def test_get_success(self, app):
def test_get_success_saas_path(self, app):
api = TenantListApi()
method = unwrap(api.get)
tenant1 = MagicMock(
id="t1",
name="Tenant 1",
status="active",
created_at=datetime.utcnow(),
)
tenant2 = MagicMock(
id="t2",
name="Tenant 2",
status="active",
created_at=datetime.utcnow(),
)
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={
"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0},
"t2": {"plan": CloudPlan.PROFESSIONAL, "expiration_date": 0},
},
) as get_plan_bulk_mock,
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api)
assert status == 200
assert len(result["workspaces"]) == 2
assert result["workspaces"][0]["current"] is True
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_not_called()
def test_get_saas_path_partial_fallback_for_missing_tenant_billing_disabled(self, app):
"""Bulk omits a tenant: only that tenant uses FeatureService; billing off -> SANDBOX."""
api = TenantListApi()
method = unwrap(api.get)
tenant1 = MagicMock(
id="t1",
name="Tenant 1",
status="active",
created_at=datetime.utcnow(),
)
tenant2 = MagicMock(
id="t2",
name="Tenant 2",
status="active",
created_at=datetime.utcnow(),
)
features_t2 = MagicMock()
features_t2.billing.enabled = False
features_t2.billing.subscription.plan = CloudPlan.PROFESSIONAL
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0}},
) as get_plan_bulk_mock,
patch(
"controllers.console.workspace.workspace.FeatureService.get_features",
return_value=features_t2,
) as get_features_mock,
):
result, status = method(api)
assert status == 200
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_called_once_with("t2")
def test_get_saas_path_partial_fallback_for_missing_tenant_billing_enabled(self, app):
"""Bulk omits a tenant: FeatureService supplies plan when billing.enabled is true."""
api = TenantListApi()
method = unwrap(api.get)
tenant1 = MagicMock(
id="t1",
name="Tenant 1",
status="active",
created_at=datetime.utcnow(),
)
tenant2 = MagicMock(
id="t2",
name="Tenant 2",
status="active",
created_at=datetime.utcnow(),
)
features_t2 = MagicMock()
features_t2.billing.enabled = True
features_t2.billing.subscription.plan = CloudPlan.PROFESSIONAL
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0}},
) as get_plan_bulk_mock,
patch(
"controllers.console.workspace.workspace.FeatureService.get_features",
return_value=features_t2,
) as get_features_mock,
):
result, status = method(api)
assert status == 200
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_called_once_with("t2")
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(self, app):
"""Test fallback to FeatureService when bulk billing returns empty result.
BillingService.get_plan_bulk catches exceptions internally and returns empty dict,
so we simulate the real failure mode by returning empty dict for non-empty input.
"""
api = TenantListApi()
method = unwrap(api.get)
@@ -54,27 +209,41 @@ class TestTenantListApi:
)
features = MagicMock()
features.billing.enabled = True
features.billing.subscription.plan = CloudPlan.SANDBOX
features.billing.enabled = False
features.billing.subscription.plan = CloudPlan.TEAM
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t1")
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t2")
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
),
patch("controllers.console.workspace.workspace.FeatureService.get_features", return_value=features),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={}, # Simulates real failure: empty result for non-empty input
) as get_plan_bulk_mock,
patch(
"controllers.console.workspace.workspace.FeatureService.get_features",
return_value=features,
) as get_features_mock,
patch("controllers.console.workspace.workspace.logger.warning") as logger_warning_mock,
):
result, status = method(api)
assert status == 200
assert len(result["workspaces"]) == 2
assert result["workspaces"][0]["current"] is True
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
assert get_features_mock.call_count == 2
logger_warning_mock.assert_called_once()
def test_get_billing_disabled(self, app):
def test_get_billing_disabled_community_path(self, app):
api = TenantListApi()
method = unwrap(api.get)
@@ -87,6 +256,7 @@ class TestTenantListApi:
features = MagicMock()
features.billing.enabled = False
features.billing.subscription.plan = CloudPlan.SANDBOX
with (
app.test_request_context("/workspaces"),
@@ -98,15 +268,83 @@ class TestTenantListApi:
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch(
"controllers.console.workspace.workspace.FeatureService.get_features",
return_value=features,
),
) as get_features_mock,
):
result, status = method(api)
assert status == 200
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
get_features_mock.assert_called_once_with("t1")
def test_get_enterprise_only_skips_feature_service(self, app):
api = TenantListApi()
method = unwrap(api.get)
tenant1 = MagicMock(
id="t1",
name="Tenant 1",
status="active",
created_at=datetime.utcnow(),
)
tenant2 = MagicMock(
id="t2",
name="Tenant 2",
status="active",
created_at=datetime.utcnow(),
)
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), "t2")
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[tenant1, tenant2],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api)
assert status == 200
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][0]["current"] is False
assert result["workspaces"][1]["current"] is True
get_features_mock.assert_not_called()
def test_get_enterprise_only_with_empty_tenants(self, app):
api = TenantListApi()
method = unwrap(api.get)
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.current_account_with_tenant", return_value=(MagicMock(), None)
),
patch(
"controllers.console.workspace.workspace.TenantService.get_join_tenants",
return_value=[],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api)
assert status == 200
assert result["workspaces"] == []
get_features_mock.assert_not_called()
class TestWorkspaceListApi:

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index'
@@ -14,7 +14,7 @@ describe('AppTypeSelector', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
@@ -39,24 +39,27 @@ describe('AppTypeSelector', () => {
// Covers opening/closing the dropdown and selection updates.
describe('User interactions', () => {
it('should toggle option list when clicking the trigger', () => {
it('should close option list when clicking outside', () => {
render(<AppTypeSelector value={[]} onChange={vi.fn()} />)
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByRole('list')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.getByRole('tooltip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
expect(screen.getByRole('list')).toBeInTheDocument()
fireEvent.click(screen.getByText('app.typeSelector.all'))
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
fireEvent.pointerDown(document.body)
fireEvent.click(document.body)
return waitFor(() => {
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
})
it('should call onChange with added type when selecting an unselected item', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.all'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW])
})
@@ -65,8 +68,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.WORKFLOW]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.workflow'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' }))
expect(onChange).toHaveBeenCalledWith([])
})
@@ -75,8 +78,8 @@ describe('AppTypeSelector', () => {
const onChange = vi.fn()
render(<AppTypeSelector value={[AppModeEnum.CHAT]} onChange={onChange} />)
fireEvent.click(screen.getByText('app.typeSelector.chatbot'))
fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent'))
fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' }))
fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' }))
expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT])
})
@@ -88,7 +91,7 @@ describe('AppTypeSelector', () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(onChange).toHaveBeenCalledWith([])
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument()
})
})
})

View File

@@ -4,13 +4,12 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import Checkbox from '../../base/checkbox'
export type AppSelectorProps = {
value: Array<AppModeEnum>
@@ -22,43 +21,43 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
const triggerLabel = value.length === 0
? t('typeSelector.all', { ns: 'app' })
: value.map(type => getAppTypeLabel(type, t)).join(', ')
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex cursor-pointer items-center justify-between space-x-1 rounded-md px-2 hover:bg-state-base-hover',
<PopoverTrigger
aria-label={triggerLabel}
className={cn(
'flex cursor-pointer items-center justify-between rounded-md px-2 hover:bg-state-base-hover',
value.length > 0 && 'pr-7',
)}
>
<AppTypeSelectTrigger values={value} />
</PopoverTrigger>
{value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2"
onClick={() => onChange([])}
>
<AppTypeSelectTrigger values={value} />
{value && value.length > 0 && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group h-4 w-4"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<ul className="relative w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
<RiCloseCircleFill
className="h-3.5 w-3.5 text-text-quaternary group-hover:text-text-tertiary"
/>
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<ul className="relative w-full p-1">
{allTypes.map(mode => (
<AppTypeSelectorItem
key={mode}
@@ -73,9 +72,9 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
/>
))}
</ul>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}
@@ -173,33 +172,54 @@ type AppTypeSelectorItemProps = {
}
function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) {
return (
<li className="flex cursor-pointer items-center space-x-2 rounded-lg py-1 pl-2 pr-1 hover:bg-state-base-hover" onClick={onClick}>
<Checkbox checked={checked} />
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
<li>
<button
type="button"
className="flex w-full items-center space-x-2 rounded-lg py-1 pl-2 pr-1 text-left hover:bg-state-base-hover"
aria-pressed={checked}
onClick={onClick}
>
<span
aria-hidden="true"
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px] shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line h-3 w-3" />}
</span>
<AppTypeIcon type={type} />
<div className="grow p-1 pl-0">
<AppTypeLabel type={type} className="system-sm-medium text-components-menu-item-text" />
</div>
</button>
</li>
)
}
function getAppTypeLabel(type: AppModeEnum, t: ReturnType<typeof useTranslation>['t']) {
if (type === AppModeEnum.CHAT)
return t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
return t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
return t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
return t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
return t('typeSelector.workflow', { ns: 'app' })
return ''
}
type AppTypeLabelProps = {
type: AppModeEnum
className?: string
}
export function AppTypeLabel({ type, className }: AppTypeLabelProps) {
const { t } = useTranslation()
let label = ''
if (type === AppModeEnum.CHAT)
label = t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
label = t('typeSelector.agent', { ns: 'app' })
if (type === AppModeEnum.COMPLETION)
label = t('typeSelector.completion', { ns: 'app' })
if (type === AppModeEnum.ADVANCED_CHAT)
label = t('typeSelector.advanced', { ns: 'app' })
if (type === AppModeEnum.WORKFLOW)
label = t('typeSelector.workflow', { ns: 'app' })
return <span className={className}>{label}</span>
return <span className={className}>{getAppTypeLabel(type, t)}</span>
}

View File

@@ -14,23 +14,11 @@ vi.mock('@/service/tag', () => ({
fetchTagList,
}))
// Mock ahooks to avoid timer-related issues in tests
vi.mock('ahooks', () => {
return {
useDebounceFn: (fn: (...args: unknown[]) => void) => {
const ref = React.useRef(fn)
ref.current = fn
const stableRun = React.useRef((...args: unknown[]) => {
// Schedule to run after current event handler finishes,
// allowing React to process pending state updates first
Promise.resolve().then(() => ref.current(...args))
})
return { run: stableRun.current }
},
useMount: (fn: () => void) => {
React.useEffect(() => {
fn()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
},
}
@@ -228,7 +216,6 @@ describe('TagFilter', () => {
const searchInput = screen.getByRole('textbox')
await user.type(searchInput, 'Front')
// With debounce mocked to be synchronous, results should be immediate
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
expect(screen.queryByText('API Design')).not.toBeInTheDocument()
@@ -257,22 +244,14 @@ describe('TagFilter', () => {
const searchInput = screen.getByRole('textbox')
await user.type(searchInput, 'Front')
// Wait for the debounced search to filter
await waitFor(() => {
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
})
expect(screen.queryByText('Backend')).not.toBeInTheDocument()
// Clear the search using the Input's clear button
const clearButton = screen.getByTestId('input-clear')
await user.click(clearButton)
// The input value should be cleared
expect(searchInput).toHaveValue('')
// After the clear + microtask re-render, all app tags should be visible again
await waitFor(() => {
expect(screen.getByText('Backend')).toBeInTheDocument()
})
expect(screen.getByText('Backend')).toBeInTheDocument()
expect(screen.getByText('Frontend')).toBeInTheDocument()
expect(screen.getByText('API Design')).toBeInTheDocument()
})

View File

@@ -1,15 +1,15 @@
import type { FC } from 'react'
import type { Tag } from '@/app/components/base/tag-management/constant'
import { useDebounceFn, useMount } from 'ahooks'
import { useMount } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { fetchTagList } from '@/service/tag'
import { cn } from '@/utils/classnames'
@@ -33,18 +33,10 @@ const TagFilter: FC<TagFilterProps> = ({
const setShowTagManagementModal = useTagStore(s => s.setShowTagManagementModal)
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredTagList = useMemo(() => {
return tagList.filter(tag => tag.type === type && tag.name.includes(searchKeywords))
}, [type, tagList, searchKeywords])
return tagList.filter(tag => tag.type === type && tag.name.includes(keywords))
}, [type, tagList, keywords])
const currentTag = useMemo(() => {
return tagList.find(tag => tag.id === value[0])
@@ -64,61 +56,61 @@ const TagFilter: FC<TagFilterProps> = ({
})
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2',
!open && !!value.length && 'shadow-xs',
open && !!value.length && 'shadow-xs',
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
</div>
<div className="text-[13px] leading-[18px] text-text-secondary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentTag?.name}
</div>
{value.length > 1 && (
<div className="text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<PopoverTrigger
render={(
<button
type="button"
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left',
!!value.length && 'pr-6 shadow-xs',
)}
>
<div className="p-[1px]">
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-trigger-icon" />
</div>
)}
{!!value.length && (
<div
className="group/clear cursor-pointer p-[1px]"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
data-testid="tag-filter-clear-button"
>
<span className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-secondary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentTag?.name}
</div>
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
{value.length > 1 && (
<div className="shrink-0 text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="shrink-0 p-[1px]">
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 text-text-tertiary" data-testid="tag-filter-arrow-down-icon" />
</div>
)}
</button>
)}
/>
{!!value.length && (
<button
type="button"
className="group/clear absolute right-2 top-1/2 -translate-y-1/2 p-[1px]"
onClick={() => onChange([])}
data-testid="tag-filter-clear-button"
>
<span className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="relative">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="max-h-72 overflow-auto p-1">
@@ -155,9 +147,9 @@ const TagFilter: FC<TagFilterProps> = ({
</div>
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@@ -13,12 +13,20 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
}))
vi.mock('@/app/components/base/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast')>()
return {
...actual,
default: Object.assign(actual.default, {
notify: mockToastNotify,
}),
}
})
const mockCreateEmptyDataset = vi.fn()
const mockInvalidDatasetList = vi.fn()
@@ -37,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({
describe('CreateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify.mockReset()
mockToastNotify.mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {

View File

@@ -3,7 +3,7 @@ import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useRouter } from '@/next/navigation'
import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
@@ -20,9 +20,9 @@ const CreateCard = () => {
onSuccess: (data) => {
if (data) {
const { id } = data
Toast.notify({
toast.add({
type: 'success',
message: t('creation.successTip', { ns: 'datasetPipeline' }),
title: t('creation.successTip', { ns: 'datasetPipeline' }),
})
invalidDatasetList()
trackEvent('create_datasets_from_scratch', {
@@ -32,9 +32,9 @@ const CreateCard = () => {
}
},
onError: () => {
Toast.notify({
toast.add({
type: 'error',
message: t('creation.errorTip', { ns: 'datasetPipeline' }),
title: t('creation.errorTip', { ns: 'datasetPipeline' }),
})
},
})

View File

@@ -1,8 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import EditPipelineInfo from '../edit-pipeline-info'
@@ -16,12 +14,21 @@ vi.mock('@/service/use-pipeline', () => ({
useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock AppIconPicker to capture interactions
let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined
let _mockOnClose: (() => void) | undefined
@@ -88,6 +95,7 @@ describe('EditPipelineInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
_mockOnSelect = undefined
_mockOnClose = undefined
})
@@ -235,9 +243,9 @@ describe('EditPipelineInfo', () => {
fireEvent.click(saveButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
title: 'datasetPipeline.editPipelineInfoNameRequired',
})
})
})

View File

@@ -1,7 +1,6 @@
import type { PipelineTemplate } from '@/models/pipeline'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import { ChunkingMode } from '@/models/datasets'
import TemplateCard from '../index'
@@ -15,12 +14,21 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: vi.fn(),
},
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock download utilities
vi.mock('@/utils/download', () => ({
downloadBlob: vi.fn(),
@@ -174,6 +182,7 @@ describe('TemplateCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockIsExporting = false
_capturedOnConfirm = undefined
_capturedOnCancel = undefined
@@ -228,9 +237,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -291,9 +300,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -309,9 +318,9 @@ describe('TemplateCard', () => {
fireEvent.click(chooseButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -458,9 +467,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})
@@ -476,9 +485,9 @@ describe('TemplateCard', () => {
fireEvent.click(exportButton)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@@ -9,7 +9,7 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
type EditPipelineInfoProps = {
@@ -67,9 +67,9 @@ const EditPipelineInfo = ({
const handleSave = useCallback(async () => {
if (!name) {
Toast.notify({
toast.add({
type: 'error',
message: 'Please enter a name for the Knowledge Base.',
title: t('editPipelineInfoNameRequired', { ns: 'datasetPipeline' }),
})
return
}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Confirm from '@/app/components/base/confirm'
import Modal from '@/app/components/base/modal'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { useRouter } from '@/next/navigation'
import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset'
@@ -50,9 +50,9 @@ const TemplateCard = ({
const handleUseTemplate = useCallback(async () => {
const { data: pipelineTemplateInfo } = await getPipelineTemplateInfo()
if (!pipelineTemplateInfo) {
Toast.notify({
toast.add({
type: 'error',
message: t('creation.errorTip', { ns: 'datasetPipeline' }),
title: t('creation.errorTip', { ns: 'datasetPipeline' }),
})
return
}
@@ -61,9 +61,9 @@ const TemplateCard = ({
}
await createDataset(request, {
onSuccess: async (newDataset) => {
Toast.notify({
toast.add({
type: 'success',
message: t('creation.successTip', { ns: 'datasetPipeline' }),
title: t('creation.successTip', { ns: 'datasetPipeline' }),
})
invalidDatasetList()
if (newDataset.pipeline_id)
@@ -76,9 +76,9 @@ const TemplateCard = ({
push(`/datasets/${newDataset.dataset_id}/pipeline`)
},
onError: () => {
Toast.notify({
toast.add({
type: 'error',
message: t('creation.errorTip', { ns: 'datasetPipeline' }),
title: t('creation.errorTip', { ns: 'datasetPipeline' }),
})
},
})
@@ -109,15 +109,15 @@ const TemplateCard = ({
onSuccess: (res) => {
const blob = new Blob([res.data], { type: 'application/yaml' })
downloadBlob({ data: blob, fileName: `${pipeline.name}.pipeline` })
Toast.notify({
toast.add({
type: 'success',
message: t('exportDSL.successTip', { ns: 'datasetPipeline' }),
title: t('exportDSL.successTip', { ns: 'datasetPipeline' }),
})
},
onError: () => {
Toast.notify({
toast.add({
type: 'error',
message: t('exportDSL.errorTip', { ns: 'datasetPipeline' }),
title: t('exportDSL.errorTip', { ns: 'datasetPipeline' }),
})
},
})

View File

@@ -32,16 +32,21 @@ vi.mock('@/service/base', () => ({
ssePost: mockSsePost,
}))
// Mock Toast.notify - static method that manipulates DOM, needs mocking to verify calls
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
// Mock toast.add because the component reports errors through the UI toast manager.
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useGetDataSourceAuth - API service hook requires mocking
const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({
@@ -192,6 +197,7 @@ const createDefaultProps = (overrides?: Partial<OnlineDocumentsProps>): OnlineDo
describe('OnlineDocuments', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
mockStoreState.documentsData = []
@@ -509,9 +515,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Something went wrong',
title: 'Something went wrong',
})
})
})
@@ -774,9 +780,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'API Error Message',
title: 'API Error Message',
})
})
})
@@ -1094,9 +1100,9 @@ describe('OnlineDocuments', () => {
render(<OnlineDocuments {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to fetch documents',
title: 'Failed to fetch documents',
})
})

View File

@@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import Loading from '@/app/components/base/loading'
import SearchInput from '@/app/components/base/notion-page-selector/search-input'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
@@ -96,9 +96,9 @@ const OnlineDocuments = ({
setDocumentsData(documentsData.data as DataSourceNotionWorkspace[])
},
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
Toast.notify({
toast.add({
type: 'error',
message: error.error,
title: error.error,
})
},
},

View File

@@ -45,15 +45,20 @@ vi.mock('@/service/use-datasource', () => ({
useGetDataSourceAuth: mockUseGetDataSourceAuth,
}))
const { mockToastNotify } = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: mockToastNotify,
},
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Note: zustand/react/shallow useShallow is imported directly (simple utility function)
@@ -231,6 +236,7 @@ const resetMockStoreState = () => {
describe('OnlineDrive', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
// Reset store state
resetMockStoreState()
@@ -541,9 +547,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@@ -915,9 +921,9 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})

View File

@@ -4,7 +4,7 @@ import type { DataSourceNodeCompletedResponse, DataSourceNodeErrorResponse } fro
import { produce } from 'immer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
@@ -105,9 +105,9 @@ const OnlineDrive = ({
isLoadingRef.current = false
},
onDataSourceNodeError: (error: DataSourceNodeErrorResponse) => {
Toast.notify({
toast.add({
type: 'error',
message: error.error,
title: error.error,
})
setIsLoading(false)
isLoadingRef.current = false

View File

@@ -1,13 +1,26 @@
import type { MockInstance } from 'vitest'
import type { RAGPipelineVariables } from '@/models/pipeline'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import { CrawlStep } from '@/models/datasets'
import { PipelineInputVarType } from '@/models/pipeline'
import Options from '../index'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock useInitialData and useConfigurations hooks
const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({
mockUseInitialData: vi.fn(),
@@ -116,13 +129,9 @@ const createDefaultProps = (overrides?: Partial<OptionsProps>): OptionsProps =>
})
describe('Options', () => {
let toastNotifySpy: MockInstance
beforeEach(() => {
vi.clearAllMocks()
// Spy on Toast.notify instead of mocking the entire module
toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
// Reset mock form values
Object.keys(mockFormValues).forEach(key => delete mockFormValues[key])
@@ -132,10 +141,6 @@ describe('Options', () => {
mockUseConfigurations.mockReturnValue([createMockConfiguration()])
})
afterEach(() => {
toastNotifySpy.mockRestore()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const props = createDefaultProps()
@@ -638,7 +643,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called with error message
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -660,10 +665,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast message should contain field path
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.stringContaining('email_address'),
title: expect.stringContaining('email_address'),
}),
)
})
@@ -714,8 +719,8 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - Toast should be called once (only first error)
expect(toastNotifySpy).toHaveBeenCalledTimes(1)
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledTimes(1)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
@@ -738,7 +743,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
// Assert - No toast error, onSubmit called
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
expect(mockOnSubmit).toHaveBeenCalled()
})
@@ -835,7 +840,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).toHaveBeenCalled()
expect(toastNotifySpy).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
it('should fail validation with invalid data', () => {
@@ -854,7 +859,7 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(mockOnSubmit).not.toHaveBeenCalled()
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
it('should show error toast message when validation fails', () => {
@@ -871,10 +876,10 @@ describe('Options', () => {
fireEvent.click(screen.getByRole('button'))
expect(toastNotifySpy).toHaveBeenCalledWith(
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.any(String),
title: expect.any(String),
}),
)
})

View File

@@ -8,7 +8,7 @@ import { useAppForm } from '@/app/components/base/form'
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
import { generateZodSchema } from '@/app/components/base/form/form-scenarios/base/utils'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useConfigurations, useInitialData } from '@/app/components/rag-pipeline/hooks/use-input-fields'
import { CrawlStep } from '@/models/datasets'
import { cn } from '@/utils/classnames'
@@ -44,9 +44,9 @@ const Options = ({
const issues = result.error.issues
const firstIssue = issues[0]
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
Toast.notify({
toast.add({
type: 'error',
message: errorMessage,
title: errorMessage,
})
return errorMessage
}

View File

@@ -1,13 +1,24 @@
import type { NotionPage } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import Toast from '@/app/components/base/toast'
import OnlineDocumentPreview from '../online-document-preview'
// Uses global react-i18next mock from web/vitest.setup.ts
// Spy on Toast.notify
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock dataset-detail context - needs mock to control return values
const mockPipelineId = vi.fn()
@@ -56,6 +67,7 @@ const defaultProps = {
describe('OnlineDocumentPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
mockToastAdd.mockReset()
mockPipelineId.mockReturnValue('pipeline-123')
mockUsePreviewOnlineDocument.mockReturnValue({
mutateAsync: mockMutateAsync,
@@ -258,9 +270,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: errorMessage,
title: errorMessage,
})
})
})
@@ -276,9 +288,9 @@ describe('OnlineDocumentPreview', () => {
render(<OnlineDocumentPreview {...defaultProps} />)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'Network Error',
title: 'Network Error',
})
})
})

View File

@@ -6,7 +6,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Notion } from '@/app/components/base/icons/src/public/common'
import { Markdown } from '@/app/components/base/markdown'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { usePreviewOnlineDocument } from '@/service/use-pipeline'
import { formatNumberAbbreviated } from '@/utils/format'
@@ -44,9 +44,9 @@ const OnlineDocumentPreview = ({
setContent(data.content)
},
onError(error) {
Toast.notify({
toast.add({
type: 'error',
message: error.message,
title: error.message,
})
},
})

View File

@@ -3,13 +3,24 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import * as z from 'zod'
import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
import Toast from '@/app/components/base/toast'
import Actions from '../actions'
import Form from '../form'
import Header from '../header'
// Spy on Toast.notify for validation tests
const toastNotifySpy = vi.spyOn(Toast, 'notify')
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Test Data Factory Functions
@@ -335,7 +346,7 @@ describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
toastNotifySpy.mockClear()
mockToastAdd.mockReset()
})
describe('Rendering', () => {
@@ -444,9 +455,9 @@ describe('Form', () => {
// Assert - validation error should be shown
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@@ -566,9 +577,9 @@ describe('Form', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: '"field1" is required',
title: '"field1" is required',
})
})
})
@@ -583,7 +594,7 @@ describe('Form', () => {
// Assert - wait a bit and verify onSubmit was not called
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalled()
})
expect(onSubmit).not.toHaveBeenCalled()
})

View File

@@ -2,10 +2,23 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
import Toast from '@/app/components/base/toast'
import Form from '../form'
const { mockToastAdd } = vi.hoisted(() => ({
mockToastAdd: vi.fn(),
}))
vi.mock('@/app/components/base/ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
add: mockToastAdd,
},
}
})
// Mock the Header component (sibling component, not a base component)
vi.mock('../header', () => ({
default: ({ onReset, resetDisabled, onPreview, previewDisabled }: {
@@ -44,7 +57,7 @@ const defaultProps = {
describe('Form (process-documents)', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
mockToastAdd.mockReset()
})
// Verify basic rendering of form structure
@@ -106,8 +119,11 @@ describe('Form (process-documents)', () => {
fireEvent.submit(form)
await waitFor(() => {
expect(Toast.notify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' }),
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
title: '"name" Name is required',
}),
)
})
})
@@ -121,7 +137,7 @@ describe('Form (process-documents)', () => {
await waitFor(() => {
expect(defaultProps.onSubmit).toHaveBeenCalled()
})
expect(Toast.notify).not.toHaveBeenCalled()
expect(mockToastAdd).not.toHaveBeenCalled()
})
})

View File

@@ -3,7 +3,7 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario
import { useCallback, useImperativeHandle } from 'react'
import { useAppForm } from '@/app/components/base/form'
import BaseField from '@/app/components/base/form/form-scenarios/base/field'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import Header from './header'
type OptionsProps = {
@@ -34,9 +34,9 @@ const Form = ({
const issues = result.error.issues
const firstIssue = issues[0]
const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
Toast.notify({
toast.add({
type: 'error',
message: errorMessage,
title: errorMessage,
})
return errorMessage
}

View File

@@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
// Verify success notification
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
// Verify navigation back
@@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
title: 'Failed to connect External Knowledge Base',
title: 'dataset.externalKnowledgeForm.connectedFailed',
})
})
@@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => {
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
title: 'External Knowledge Base Connected Successfully',
title: 'dataset.externalKnowledgeForm.connectedSuccess',
})
})
})

View File

@@ -3,6 +3,7 @@
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { toast } from '@/app/components/base/ui/toast'
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
@@ -12,13 +13,14 @@ import { createExternalKnowledgeBase } from '@/service/datasets'
const ExternalKnowledgeBaseConnector = () => {
const [loading, setLoading] = useState(false)
const router = useRouter()
const { t } = useTranslation()
const handleConnect = async (formValue: CreateKnowledgeBaseReq) => {
try {
setLoading(true)
const result = await createExternalKnowledgeBase({ body: formValue })
if (result && result.id) {
toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' })
toast.add({ type: 'success', title: t('externalKnowledgeForm.connectedSuccess', { ns: 'dataset' }) })
trackEvent('create_external_knowledge_base', {
provider: formValue.provider,
name: formValue.name,
@@ -29,7 +31,7 @@ const ExternalKnowledgeBaseConnector = () => {
}
catch (error) {
console.error('Error creating external knowledge base:', error)
toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' })
toast.add({ type: 'error', title: t('externalKnowledgeForm.connectedFailed', { ns: 'dataset' }) })
}
setLoading(false)
}

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks
@@ -17,44 +18,73 @@ vi.mock('@/i18n-config/language', () => ({
],
}))
// Mock PortalSelect component
vi.mock('@/app/components/base/select', () => ({
PortalSelect: ({
const MockSelectContext = React.createContext<{
value: string
onValueChange: (value: string) => void
}>({
value: '',
onValueChange: () => {},
})
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({
value,
items,
onSelect,
triggerClassName,
popupClassName,
popupInnerClassName,
onValueChange,
children,
}: {
value: string
items: Array<{ value: string, name: string }>
onSelect: (item: { value: string }) => void
triggerClassName?: string
popupClassName?: string
popupInnerClassName?: string
onValueChange: (value: string) => void
children: React.ReactNode
}) => (
<div
data-testid="portal-select"
data-value={value}
data-trigger-class={triggerClassName}
data-popup-class={popupClassName}
data-popup-inner-class={popupInnerClassName}
>
<span data-testid="selected-value">{value}</span>
<div data-testid="items-container">
{items.map(item => (
<button
key={item.value}
data-testid={`select-item-${item.value}`}
onClick={() => onSelect({ value: item.value })}
>
{item.name}
</button>
))}
</div>
<MockSelectContext.Provider value={{ value, onValueChange }}>
<div data-testid="select-root">{children}</div>
</MockSelectContext.Provider>
),
SelectTrigger: ({
children,
className,
'data-testid': testId,
}: {
'children': React.ReactNode
'className'?: string
'data-testid'?: string
}) => (
<button data-testid={testId ?? 'select-trigger'} data-class={className}>
{children}
</button>
),
SelectValue: () => {
const { value } = React.useContext(MockSelectContext)
return <span data-testid="selected-value">{value}</span>
},
SelectContent: ({
children,
popupClassName,
}: {
children: React.ReactNode
popupClassName?: string
}) => (
<div data-testid="select-content" data-popup-class={popupClassName}>
{children}
</div>
),
SelectItem: ({
children,
value,
}: {
children: React.ReactNode
value: string
}) => {
const { onValueChange } = React.useContext(MockSelectContext)
return (
<button
data-testid={`select-item-${value}`}
onClick={() => onValueChange(value)}
>
{children}
</button>
)
},
}))
// ==================== Test Utilities ====================
@@ -139,7 +169,7 @@ describe('TTSParamsPanel', () => {
expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument()
})
it('should render two PortalSelect components', () => {
it('should render two Select components', () => {
// Arrange
const props = createDefaultProps()
@@ -147,7 +177,7 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
const selects = screen.getAllByTestId('select-root')
expect(selects).toHaveLength(2)
})
@@ -159,8 +189,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('zh-Hans')
})
it('should render voice select with correct value', () => {
@@ -171,8 +201,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'echo')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('echo')
})
it('should only show supported languages in language select', () => {
@@ -205,7 +235,7 @@ describe('TTSParamsPanel', () => {
// ==================== Props Testing ====================
describe('Props', () => {
it('should apply trigger className to PortalSelect', () => {
it('should apply trigger className to SelectTrigger', () => {
// Arrange
const props = createDefaultProps()
@@ -213,12 +243,11 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8')
expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8')
expect(screen.getByTestId('tts-language-select-trigger')).toHaveAttribute('data-class', 'w-full')
expect(screen.getByTestId('tts-voice-select-trigger')).toHaveAttribute('data-class', 'w-full')
})
it('should apply popup className to PortalSelect', () => {
it('should apply popup className to SelectContent', () => {
// Arrange
const props = createDefaultProps()
@@ -226,22 +255,9 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]')
expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]')
})
it('should apply popup inner className to PortalSelect', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]')
const contents = screen.getAllByTestId('select-content')
expect(contents[0]).toHaveAttribute('data-popup-class', 'w-[354px]')
expect(contents[1]).toHaveAttribute('data-popup-class', 'w-[354px]')
})
})
@@ -411,10 +427,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert - no voice items (except language items)
const voiceSelects = screen.getAllByTestId('portal-select')
// Second select is voice select, should have no voice items in items-container
const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]')
expect(voiceItemsContainer?.children).toHaveLength(0)
expect(screen.getAllByTestId('select-content')[1].children).toHaveLength(0)
expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument()
})
it('should handle currentModel with single voice', () => {
@@ -443,8 +457,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('')
})
it('should handle empty voice value', () => {
@@ -455,8 +469,8 @@ describe('TTSParamsPanel', () => {
render(<TTSParamsPanel {...props} />)
// Assert
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', '')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('')
})
it('should handle many voices', () => {
@@ -514,14 +528,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[0]).toHaveAttribute('data-value', 'en-US')
const values = screen.getAllByTestId('selected-value')
expect(values[0]).toHaveTextContent('en-US')
rerender(<TTSParamsPanel {...props} language="zh-Hans" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[0]).toHaveTextContent('zh-Hans')
})
it('should update when voice prop changes', () => {
@@ -530,14 +544,14 @@ describe('TTSParamsPanel', () => {
// Act
const { rerender } = render(<TTSParamsPanel {...props} />)
const selects = screen.getAllByTestId('portal-select')
expect(selects[1]).toHaveAttribute('data-value', 'alloy')
const values = screen.getAllByTestId('selected-value')
expect(values[1]).toHaveTextContent('alloy')
rerender(<TTSParamsPanel {...props} voice="echo" />)
// Assert
const updatedSelects = screen.getAllByTestId('portal-select')
expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo')
const updatedValues = screen.getAllByTestId('selected-value')
expect(updatedValues[1]).toHaveTextContent('echo')
})
it('should update voice list when currentModel changes', () => {

View File

@@ -31,7 +31,6 @@ import TTSParamsPanel from './tts-params-panel'
export type ModelParameterModalProps = {
popupClassName?: string
portalToFollowElemContentClassName?: string
isAdvancedMode: boolean
value: any
setModel: (model: any) => void
@@ -44,7 +43,6 @@ export type ModelParameterModalProps = {
const ModelParameterModal: FC<ModelParameterModalProps> = ({
popupClassName,
portalToFollowElemContentClassName,
isAdvancedMode,
value,
setModel,
@@ -230,7 +228,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
<PopoverContent
placement={isInWorkflow ? 'left' : 'bottom-end'}
sideOffset={4}
className={portalToFollowElemContentClassName}
popupClassName={cn(popupClassName, 'w-[389px] rounded-2xl')}
>
<div className="max-h-[420px] overflow-y-auto p-4 pt-3">

View File

@@ -1,9 +1,8 @@
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalSelect } from '@/app/components/base/select'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { languages } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
type Props = {
currentModel: any
@@ -12,6 +11,8 @@ type Props = {
onChange: (language: string, voice: string) => void
}
const supportedLanguages = languages.filter(item => item.supported)
const TTSParamsPanel = ({
currentModel,
language,
@@ -19,11 +20,11 @@ const TTSParamsPanel = ({
onChange,
}: Props) => {
const { t } = useTranslation()
const voiceList = useMemo(() => {
const voiceList = useMemo<Array<{ label: string, value: string }>>(() => {
if (!currentModel)
return []
return currentModel.model_properties.voices.map((item: { mode: any }) => ({
...item,
return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({
label: item.name,
value: item.mode,
}))
}, [currentModel])
@@ -39,27 +40,57 @@ const TTSParamsPanel = ({
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.language', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={language}
items={languages.filter(item => item.supported)}
onSelect={item => setLanguage(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setLanguage(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-language-select-trigger"
aria-label={t('voice.voiceSettings.language', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{supportedLanguages.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mb-3">
<div className="system-sm-semibold mb-1 flex items-center py-1 text-text-secondary">
{t('voice.voiceSettings.voice', { ns: 'appDebug' })}
</div>
<PortalSelect
triggerClassName="h-8"
popupClassName={cn('z-[1000]')}
popupInnerClassName={cn('w-[354px]')}
<Select
value={voice}
items={voiceList}
onSelect={item => setVoice(item.value as string)}
/>
onValueChange={(value) => {
if (value == null)
return
setVoice(value)
}}
>
<SelectTrigger
className="w-full"
data-testid="tts-voice-select-trigger"
aria-label={t('voice.voiceSettings.voice', { ns: 'appDebug' })}
>
<SelectValue />
</SelectTrigger>
<SelectContent popupClassName="w-[354px]">
{voiceList.map(item => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)

View File

@@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => {
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
render(<CommonCreateModal {...defaultProps} builder={builder} />)
fireEvent.click(screen.getByTestId('modal-confirm'))

View File

@@ -1,21 +1,24 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../../base/toast'
import examples from '../examples'
import GetSchema from '../get-schema'
vi.mock('@/service/tools', () => ({
importSchemaFromURL: vi.fn(),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
describe('GetSchema', () => {
const notifySpy = vi.spyOn(Toast, 'notify')
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
notifySpy.mockClear()
importSchemaFromURLMock.mockReset()
render(<GetSchema onChange={mockOnChange} />)
})
@@ -27,9 +30,9 @@ describe('GetSchema', () => {
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
fireEvent.click(screen.getByText('common.operation.ok'))
expect(notifySpy).toHaveBeenCalledWith({
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
message: 'tools.createTool.urlError',
title: 'tools.createTool.urlError',
})
})

View File

@@ -10,8 +10,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { toast } from '@/app/components/base/ui/toast'
import { importSchemaFromURL } from '@/service/tools'
import Toast from '../../base/toast'
import examples from './examples'
type Props = {
@@ -27,9 +27,9 @@ const GetSchema: FC<Props> = ({
const [isParsing, setIsParsing] = useState(false)
const handleImportFromUrl = async () => {
if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) {
Toast.notify({
toast.add({
type: 'error',
message: t('createTool.urlError', { ns: 'tools' }),
title: t('createTool.urlError', { ns: 'tools' }),
})
return
}

View File

@@ -18,32 +18,11 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}),
}))
// Mock useDebounceFn to store the function and allow manual triggering
let debouncedFn: (() => void) | null = null
vi.mock('ahooks', () => ({
useDebounceFn: (fn: () => void) => {
debouncedFn = fn
return {
run: () => {
// Schedule to run after React state updates
setTimeout(() => debouncedFn?.(), 0)
},
cancel: vi.fn(),
}
},
}))
describe('LabelFilter', () => {
const mockOnChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
debouncedFn = null
})
afterEach(() => {
vi.useRealTimers()
})
// Rendering Tests
@@ -81,36 +60,23 @@ describe('LabelFilter', () => {
const trigger = screen.getByText('common.tag.placeholder')
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger))
mockTags.forEach((tag) => {
expect(screen.getByText(tag.label)).toBeInTheDocument()
})
})
it('should close dropdown when trigger is clicked again', async () => {
it('should render search input when dropdown is open', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
const trigger = screen.getByText('common.tag.placeholder')
const trigger = screen.getByText('common.tag.placeholder').closest('button')
expect(trigger).toBeInTheDocument()
// Open
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(trigger!))
expect(screen.getByText('Agent')).toBeInTheDocument()
// Close
await act(async () => {
fireEvent.click(trigger)
vi.advanceTimersByTime(10)
})
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
@@ -119,17 +85,11 @@ describe('LabelFilter', () => {
it('should call onChange with selected label when clicking a label', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledWith(['agent'])
})
@@ -137,10 +97,7 @@ describe('LabelFilter', () => {
it('should remove label from selection when clicking already selected label', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
// Find the label item in the dropdown list
const labelItems = screen.getAllByText('Agent')
@@ -149,7 +106,6 @@ describe('LabelFilter', () => {
await act(async () => {
if (dropdownItem)
fireEvent.click(dropdownItem)
vi.advanceTimersByTime(10)
})
expect(mockOnChange).toHaveBeenCalledWith([])
@@ -158,17 +114,11 @@ describe('LabelFilter', () => {
it('should add label to existing selection', async () => {
render(<LabelFilter value={['agent']} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(screen.getByText('RAG')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('RAG'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('RAG')))
expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag'])
})
@@ -179,8 +129,7 @@ describe('LabelFilter', () => {
it('should clear all selections when clear button is clicked', async () => {
render(<LabelFilter value={['agent', 'rag']} onChange={mockOnChange} />)
// Find and click the clear button (XCircle icon's parent)
const clearButton = document.querySelector('.group\\/clear')
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
expect(clearButton).toBeInTheDocument()
fireEvent.click(clearButton!)
@@ -203,21 +152,16 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// Filter by 'rag' which only matches 'rag' name
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
// Only RAG should be visible (rag contains 'rag')
expect(screen.getByTitle('RAG')).toBeInTheDocument()
// Agent should not be in the dropdown list (agent doesn't contain 'rag')
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
})
@@ -226,7 +170,6 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
@@ -234,7 +177,6 @@ describe('LabelFilter', () => {
await act(async () => {
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: 'nonexistent' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByText('common.tag.noTag')).toBeInTheDocument()
@@ -245,26 +187,21 @@ describe('LabelFilter', () => {
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
expect(screen.getByRole('textbox')).toBeInTheDocument()
await act(async () => {
const searchInput = screen.getByRole('textbox')
// First filter to show only RAG
fireEvent.change(searchInput, { target: { value: 'rag' } })
vi.advanceTimersByTime(10)
})
expect(screen.getByTitle('RAG')).toBeInTheDocument()
expect(screen.queryByTitle('Agent')).not.toBeInTheDocument()
await act(async () => {
// Clear the input
const searchInput = screen.getByRole('textbox')
fireEvent.change(searchInput, { target: { value: '' } })
vi.advanceTimersByTime(10)
})
// All labels should be visible again
@@ -310,17 +247,11 @@ describe('LabelFilter', () => {
it('should call onChange with updated array', async () => {
render(<LabelFilter value={[]} onChange={mockOnChange} />)
await act(async () => {
fireEvent.click(screen.getByText('common.tag.placeholder'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder')))
expect(screen.getByText('Agent')).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByText('Agent'))
vi.advanceTimersByTime(10)
})
await act(async () => fireEvent.click(screen.getByText('Agent')))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith(['agent'])

View File

@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { Label } from '@/app/components/tools/labels/constant'
import { RiArrowDownSLine } from '@remixicon/react'
import { useDebounceFn } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@@ -9,10 +8,10 @@ import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import Input from '@/app/components/base/input'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useTags } from '@/app/components/plugins/hooks'
import { cn } from '@/utils/classnames'
@@ -30,18 +29,10 @@ const LabelFilter: FC<LabelFilterProps> = ({
const { tags: labelList } = useTags()
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords)
}, { wait: 500 })
const handleKeywordsChange = (value: string) => {
setKeywords(value)
handleSearch()
}
const filteredLabelList = useMemo(() => {
return labelList.filter(label => label.name.includes(searchKeywords))
}, [labelList, searchKeywords])
return labelList.filter(label => label.name.includes(keywords))
}, [labelList, keywords])
const currentLabel = useMemo(() => {
return labelList.find(label => label.name === value[0])
@@ -55,72 +46,70 @@ const LabelFilter: FC<LabelFilterProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<div className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover',
!open && !!value.length && 'shadow-xs',
open && !!value.length && 'shadow-xs',
<PopoverTrigger
className={cn(
'flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-left hover:bg-components-input-bg-hover',
!!value.length && 'pr-6 shadow-xs',
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
<div className="text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
{!!value.length && (
<div
className="group/clear cursor-pointer p-[1px]"
onClick={(e) => {
e.stopPropagation()
onChange([])
}}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</div>
)}
>
<div className="p-[1px]">
<Tag01 className="h-3.5 w-3.5 text-text-tertiary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="min-w-0 truncate text-[13px] leading-[18px] text-text-tertiary">
{!value.length && t('tag.placeholder', { ns: 'common' })}
{!!value.length && currentLabel?.label}
</div>
{value.length > 1 && (
<div className="shrink-0 text-xs font-medium leading-[18px] text-text-tertiary">{`+${value.length - 1}`}</div>
)}
{!value.length && (
<div className="shrink-0 p-[1px]">
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-tertiary" />
</div>
)}
</PopoverTrigger>
{!!value.length && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear absolute right-2 top-1/2 -translate-y-1/2 p-[1px]"
data-testid="label-filter-clear-button"
onClick={() => onChange([])}
>
<XCircle className="h-3.5 w-3.5 text-text-tertiary group-hover/clear:text-text-secondary" />
</button>
)}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]"
>
<div className="relative">
<div className="p-2">
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
onChange={e => setKeywords(e.target.value)}
onClear={() => setKeywords('')}
/>
</div>
<div className="p-1">
{filteredLabelList.map(label => (
<div
<button
key={label.name}
className="flex cursor-pointer select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 hover:bg-state-base-hover"
type="button"
className="flex w-full select-none items-center gap-2 rounded-lg py-[6px] pl-3 pr-2 text-left hover:bg-state-base-hover"
onClick={() => selectLabel(label)}
>
<div title={label.label} className="grow truncate text-sm leading-5 text-text-secondary">{label.label}</div>
{value.includes(label.name) && <Check className="h-4 w-4 shrink-0 text-text-accent" />}
</div>
</button>
))}
{!filteredLabelList.length && (
<div className="flex flex-col items-center gap-1 p-3">
@@ -130,9 +119,9 @@ const LabelFilter: FC<LabelFilterProps> = ({
)}
</div>
</div>
</PortalToFollowElemContent>
</PopoverContent>
</div>
</PortalToFollowElem>
</Popover>
)
}

View File

@@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MCPModal from '../modal'
// Mock the service API
@@ -48,7 +48,18 @@ vi.mock('@/service/use-plugins', () => ({
}),
}))
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: mockToastAdd,
},
}))
describe('MCPModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -299,6 +310,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerUrl',
})
})
it('should not call onConfirm with invalid server identifier', async () => {
@@ -320,6 +335,10 @@ describe('MCPModal', () => {
// Wait a bit and verify onConfirm was not called
await new Promise(resolve => setTimeout(resolve, 100))
expect(onConfirm).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith({
type: 'error',
title: 'tools.mcp.modal.invalidServerIdentifier',
})
})
})

View File

@@ -14,7 +14,7 @@ import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import TabSlider from '@/app/components/base/tab-slider'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { MCPAuthMethod } from '@/app/components/tools/types'
import { cn } from '@/utils/classnames'
import { shouldUseMcpIconForAppIcon } from '@/utils/mcp'
@@ -82,11 +82,11 @@ const MCPModalContent: FC<MCPModalContentProps> = ({
const submit = async () => {
if (!isValidUrl(state.url)) {
Toast.notify({ type: 'error', message: 'invalid server url' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerUrl', { ns: 'tools' }) })
return
}
if (!isValidServerID(state.serverIdentifier.trim())) {
Toast.notify({ type: 'error', message: 'invalid server identifier' })
toast.add({ type: 'error', title: t('mcp.modal.invalidServerIdentifier', { ns: 'tools' }) })
return
}
const formattedHeaders = state.headers.reduce((acc, item) => {

View File

@@ -70,11 +70,11 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({
},
}))
// Mock Toast
// Mock toast
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (options: { type: string, message: string }) => mockToastNotify(options),
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
add: (options: { type: string, title: string }) => mockToastNotify(options),
},
}))
@@ -200,7 +200,7 @@ describe('CustomCreateCard', () => {
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'success',
message: expect.any(String),
title: expect.any(String),
})
})
})

View File

@@ -92,8 +92,9 @@ vi.mock('@/app/components/base/confirm', () => ({
: null,
}))
vi.mock('@/app/components/base/toast', () => ({
default: { notify: vi.fn() },
const mockToastAdd = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/ui/toast', () => ({
toast: { add: mockToastAdd },
}))
vi.mock('@/app/components/header/indicator', () => ({

View File

@@ -5,7 +5,7 @@ import {
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useAppContext } from '@/context/app-context'
import { createCustomCollection } from '@/service/tools'
@@ -21,9 +21,9 @@ const Contribute = ({ onRefreshData }: Props) => {
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
onRefreshData()

View File

@@ -13,7 +13,7 @@ import Confirm from '@/app/components/base/confirm'
import Drawer from '@/app/components/base/drawer'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { toast } from '@/app/components/base/ui/toast'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
@@ -122,18 +122,18 @@ const ProviderDetail = ({
await getCustomProvider()
// Use fresh data from form submission to avoid race condition with collection.labels
setCustomCollection(prev => prev ? { ...prev, labels: data.labels } : null)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
const doRemoveCustomToolCollection = async () => {
await removeCustomCollection(collection?.name as string)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditCustomCollectionModal(false)
}
@@ -161,9 +161,9 @@ const ProviderDetail = ({
const removeWorkflowToolProvider = async () => {
await deleteWorkflowTool(collection.id)
onRefreshData()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@@ -175,9 +175,9 @@ const ProviderDetail = ({
invalidateAllWorkflowTools()
onRefreshData()
getWorkflowToolProvider()
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
setIsShowEditWorkflowToolModal(false)
}
@@ -385,18 +385,18 @@ const ProviderDetail = ({
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)
}}
onRemove={async () => {
await removeBuiltInToolCredential(collection.name)
Toast.notify({
toast.add({
type: 'success',
message: t('api.actionSuccess', { ns: 'common' }),
title: t('api.actionSuccess', { ns: 'common' }),
})
await onRefreshData()
setShowSettingAuth(false)

View File

@@ -1325,9 +1325,6 @@
}
},
"app/components/app/type-selector/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
@@ -3076,9 +3073,6 @@
}
},
"app/components/datasets/create-from-pipeline/list/create-card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
@@ -3112,16 +3106,13 @@
}
},
"app/components/datasets/create-from-pipeline/list/template-card/edit-pipeline-info.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/datasets/create-from-pipeline/list/template-card/index.tsx": {
"no-restricted-imports": {
"count": 3
"count": 2
}
},
"app/components/datasets/create-from-pipeline/list/template-card/operations.tsx": {
@@ -3403,9 +3394,6 @@
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@@ -3482,9 +3470,6 @@
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 5
}
@@ -3533,9 +3518,6 @@
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
@@ -3562,9 +3544,6 @@
}
},
"app/components/datasets/documents/create-from-pipeline/preview/online-document-preview.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 4
}
@@ -3578,9 +3557,6 @@
}
},
"app/components/datasets/documents/create-from-pipeline/process-documents/form.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 3
}
@@ -5232,14 +5208,11 @@
}
},
"app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 2
"count": 1
}
},
"app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": {
@@ -5955,9 +5928,6 @@
}
},
"app/components/tools/edit-custom-collection-modal/get-schema.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
},
@@ -5996,14 +5966,6 @@
"count": 1
}
},
"app/components/tools/labels/filter.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 1
}
},
"app/components/tools/labels/selector.tsx": {
"no-restricted-imports": {
"count": 1
@@ -6091,7 +6053,7 @@
},
"app/components/tools/mcp/modal.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 7
@@ -6132,16 +6094,13 @@
}
},
"app/components/tools/provider/custom-create-card.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
}
},
"app/components/tools/provider/detail.tsx": {
"no-restricted-imports": {
"count": 2
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 10

View File

@@ -116,7 +116,6 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
'app/components/base/select/index.tsx',
'app/components/base/select/pure.tsx',
'app/components/base/sort/index.tsx',
'app/components/base/tag-management/filter.tsx',
'app/components/base/theme-selector.tsx',
'app/components/base/tooltip/index.tsx',
]

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.",
"documentSettings.title": "إعدادات المستند",
"editPipelineInfo": "تعديل معلومات سير العمل",
"editPipelineInfoNameRequired": "يرجى إدخال اسم لقاعدة المعرفة.",
"exportDSL.errorTip": "فشل تصدير DSL لسير العمل",
"exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح",
"inputField": "حقل الإدخال",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Die Blockstruktur bestimmt, wie Dokumente aufgeteilt und indiziert werden, und bietet die Modi \"Allgemein\", \"Über-Eltern-Kind\" und \"Q&A\" und ist für jede Wissensdatenbank einzigartig.",
"documentSettings.title": "Dokument-Einstellungen",
"editPipelineInfo": "Bearbeiten von Pipeline-Informationen",
"editPipelineInfoNameRequired": "Bitte geben Sie einen Namen für die Wissensdatenbank ein.",
"exportDSL.errorTip": "Fehler beim Exportieren der Pipeline-DSL",
"exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren",
"inputField": "Eingabefeld",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure determines how documents are split and indexed—offering General, Parent-Child, and Q&A modes—and is unique to each knowledge base.",
"documentSettings.title": "Document Settings",
"editPipelineInfo": "Edit pipeline info",
"editPipelineInfoNameRequired": "Please enter a name for the Knowledge Base.",
"exportDSL.errorTip": "Failed to export pipeline DSL",
"exportDSL.successTip": "Export pipeline DSL successfully",
"inputField": "Input Field",

View File

@@ -77,6 +77,8 @@
"externalKnowledgeDescriptionPlaceholder": "Describe what's in this Knowledge Base (optional)",
"externalKnowledgeForm.cancel": "Cancel",
"externalKnowledgeForm.connect": "Connect",
"externalKnowledgeForm.connectedFailed": "Failed to connect External Knowledge Base",
"externalKnowledgeForm.connectedSuccess": "External Knowledge Base Connected Successfully",
"externalKnowledgeId": "External Knowledge ID",
"externalKnowledgeIdPlaceholder": "Please enter the Knowledge ID",
"externalKnowledgeName": "External Knowledge Name",

View File

@@ -126,6 +126,8 @@
"mcp.modal.headerValuePlaceholder": "e.g., Bearer token123",
"mcp.modal.headers": "Headers",
"mcp.modal.headersTip": "Additional HTTP headers to send with MCP server requests",
"mcp.modal.invalidServerIdentifier": "Please enter a valid server identifier",
"mcp.modal.invalidServerUrl": "Please enter a valid server URL",
"mcp.modal.maskedHeadersTip": "Header values are masked for security. Changes will update the actual values.",
"mcp.modal.name": "Name & Icon",
"mcp.modal.namePlaceholder": "Name your MCP server",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La estructura de fragmentos determina cómo se dividen e indexan los documentos, ofreciendo modos General, Principal-Secundario y Preguntas y respuestas, y es única para cada base de conocimiento.",
"documentSettings.title": "Parametrizaciones de documentos",
"editPipelineInfo": "Editar información de canalización",
"editPipelineInfoNameRequired": "Por favor, ingrese un nombre para la Base de Conocimiento.",
"exportDSL.errorTip": "No se pudo exportar DSL de canalización",
"exportDSL.successTip": "Exportar DSL de canalización correctamente",
"inputField": "Campo de entrada",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.",
"documentSettings.title": "تنظیمات سند",
"editPipelineInfo": "ویرایش اطلاعات خط لوله",
"editPipelineInfoNameRequired": "لطفاً یک نام برای پایگاه دانش وارد کنید.",
"exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد",
"exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید",
"inputField": "فیلد ورودی",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La structure par blocs détermine la façon dont les documents sont divisés et indexés (en proposant les modes Général, Parent-Enfant et Q&R) et est unique à chaque base de connaissances.",
"documentSettings.title": "Paramètres du document",
"editPipelineInfo": "Modifier les informations sur le pipeline",
"editPipelineInfoNameRequired": "Veuillez saisir un nom pour la Base de connaissances.",
"exportDSL.errorTip": "Echec de lexportation du DSL du pipeline",
"exportDSL.successTip": "Pipeline dexportation DSL réussi",
"inputField": "Champ de saisie",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।",
"documentSettings.title": "डॉक्यूमेंट सेटिंग्स",
"editPipelineInfo": "पाइपलाइन जानकारी संपादित करें",
"editPipelineInfoNameRequired": "कृपया ज्ञान आधार के लिए एक नाम दर्ज करें।",
"exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल",
"exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक",
"inputField": "इनपुट फ़ील्ड",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktur Potongan menentukan bagaimana dokumen dibagi dan diindeks—menawarkan mode Umum, Induk-Anak, dan Tanya Jawab—dan unik untuk setiap basis pengetahuan.",
"documentSettings.title": "Pengaturan Dokumen",
"editPipelineInfo": "Mengedit info alur",
"editPipelineInfoNameRequired": "Silakan masukkan nama untuk Basis Pengetahuan.",
"exportDSL.errorTip": "Gagal mengekspor DSL alur",
"exportDSL.successTip": "Ekspor DSL pipeline berhasil",
"inputField": "Bidang Masukan",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "La struttura a blocchi determina il modo in cui i documenti vengono suddivisi e indicizzati, offrendo le modalità Generale, Padre-Figlio e Domande e risposte, ed è univoca per ogni knowledge base.",
"documentSettings.title": "Impostazioni documento",
"editPipelineInfo": "Modificare le informazioni sulla pipeline",
"editPipelineInfoNameRequired": "Inserisci un nome per la Knowledge Base.",
"exportDSL.errorTip": "Impossibile esportare il DSL della pipeline",
"exportDSL.successTip": "Esporta DSL pipeline con successo",
"inputField": "Campo di input",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "チャンク構造は、ドキュメントがどのように分割され、インデックスされるかを決定します。一般、親子、Q&Aモードを提供し、各ナレッジベースにユニークです。",
"documentSettings.title": "ドキュメント設定",
"editPipelineInfo": "パイプライン情報を編集する",
"editPipelineInfoNameRequired": "ナレッジベースの名前を入力してください。",
"exportDSL.errorTip": "パイプラインDSLのエクスポートに失敗しました",
"exportDSL.successTip": "エクスポートパイプラインDSLが成功しました",
"inputField": "入力フィールド",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.",
"documentSettings.title": "문서 설정",
"editPipelineInfo": "파이프라인 정보 편집",
"editPipelineInfoNameRequired": "기술 자료의 이름을 입력해 주세요.",
"exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.",
"exportDSL.successTip": "파이프라인 DSL 내보내기 성공",
"inputField": "입력 필드",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure determines how documents are split and indexed—offering General, Parent-Child, and Q&A modes—and is unique to each knowledge base.",
"documentSettings.title": "Document Settings",
"editPipelineInfo": "Edit pipeline info",
"editPipelineInfoNameRequired": "Voer een naam in voor de Kennisbank.",
"exportDSL.errorTip": "Failed to export pipeline DSL",
"exportDSL.successTip": "Export pipeline DSL successfully",
"inputField": "Input Field",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura fragmentów określa sposób dzielenia i indeksowania dokumentów — oferując tryby Ogólne, Nadrzędny-Podrzędny oraz Q&A — i jest unikatowa dla każdej bazy wiedzy.",
"documentSettings.title": "Ustawienia dokumentu",
"editPipelineInfo": "Edytowanie informacji o potoku",
"editPipelineInfoNameRequired": "Proszę podać nazwę Bazy Wiedzy.",
"exportDSL.errorTip": "Nie można wyeksportować DSL potoku",
"exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL",
"inputField": "Pole wejściowe",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "A Estrutura de Partes determina como os documentos são divididos e indexados, oferecendo os modos Geral, Pai-Filho e P e Resposta, e é exclusiva para cada base de conhecimento.",
"documentSettings.title": "Configurações do documento",
"editPipelineInfo": "Editar informações do pipeline",
"editPipelineInfoNameRequired": "Por favor, insira um nome para a Base de Conhecimento.",
"exportDSL.errorTip": "Falha ao exportar DSL de pipeline",
"exportDSL.successTip": "Exportar DSL de pipeline com êxito",
"inputField": "Campo de entrada",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Structura de bucăți determină modul în care documentele sunt împărțite și indexate - oferind modurile General, Părinte-Copil și Întrebări și răspunsuri - și este unică pentru fiecare bază de cunoștințe.",
"documentSettings.title": "Setări document",
"editPipelineInfo": "Editați informațiile despre conductă",
"editPipelineInfoNameRequired": "Vă rugăm să introduceți un nume pentru Baza de Cunoștințe.",
"exportDSL.errorTip": "Nu s-a reușit exportul DSL al conductei",
"exportDSL.successTip": "Exportați cu succes DSL",
"inputField": "Câmp de intrare",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.",
"documentSettings.title": "Настройки документа",
"editPipelineInfo": "Редактирование сведений о воронке продаж",
"editPipelineInfoNameRequired": "Пожалуйста, введите название базы знаний.",
"exportDSL.errorTip": "Не удалось экспортировать DSL конвейера",
"exportDSL.successTip": "Экспорт конвейера DSL успешно",
"inputField": "Поле ввода",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Struktura kosov določa, kako so dokumenti razdeljeni in indeksirani ponuja načine Splošno, Nadrejeno-podrejeno in Vprašanja in odgovori in je edinstvena za vsako zbirko znanja.",
"documentSettings.title": "Nastavitve dokumenta",
"editPipelineInfo": "Urejanje informacij o cevovodu",
"editPipelineInfoNameRequired": "Prosim vnesite ime za Bazo znanja.",
"exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel",
"exportDSL.successTip": "Uspešno izvozite DSL",
"inputField": "Vnosno polje",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้",
"documentSettings.title": "การตั้งค่าเอกสาร",
"editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์",
"editPipelineInfoNameRequired": "โปรดป้อนชื่อสำหรับฐานความรู้",
"exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้",
"exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ",
"inputField": "ฟิลด์อินพุต",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Yığın Yapısı, belgelerin nasıl bölündüğünü ve dizine eklendiğini belirler (Genel, Üst-Alt ve Soru-Cevap modları sunar) ve her bilgi bankası için benzersizdir.",
"documentSettings.title": "Belge Ayarları",
"editPipelineInfo": "İşlem hattı bilgilerini düzenleme",
"editPipelineInfoNameRequired": "Lütfen Bilgi Bankası için bir ad girin.",
"exportDSL.errorTip": "İşlem hattı DSL'si dışarı aktarılamadı",
"exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın",
"inputField": "Giriş Alanı",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.",
"documentSettings.title": "Параметри документа",
"editPipelineInfo": "Як редагувати інформацію про воронку продажів",
"editPipelineInfoNameRequired": "Будь ласка, введіть назву Бази знань.",
"exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну",
"exportDSL.successTip": "Успішний експорт DSL воронки продажів",
"inputField": "Поле введення",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "Chunk Structure xác định cách các tài liệu được phân tách và lập chỉ mục — cung cấp các chế độ General, Parent-Child và Q&A — và là duy nhất cho mỗi cơ sở tri thức.",
"documentSettings.title": "Cài đặt tài liệu",
"editPipelineInfo": "Chỉnh sửa thông tin quy trình",
"editPipelineInfoNameRequired": "Vui lòng nhập tên cho Cơ sở Kiến thức.",
"exportDSL.errorTip": "Không thể xuất DSL đường ống",
"exportDSL.successTip": "Xuất DSL quy trình thành công",
"inputField": "Trường đầu vào",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "文档结构决定了文档的拆分和索引方式Dify 提供了通用、父子和问答模式,每个知识库的文档结构是唯一的。",
"documentSettings.title": "文档设置",
"editPipelineInfo": "编辑知识流水线信息",
"editPipelineInfoNameRequired": "请输入知识库的名称。",
"exportDSL.errorTip": "导出知识流水线 DSL 失败",
"exportDSL.successTip": "成功导出知识流水线 DSL",
"inputField": "输入字段",

View File

@@ -35,6 +35,7 @@
"details.structureTooltip": "區塊結構會決定文件的分割和索引方式 (提供一般、父子和問答模式),而且每個知識庫都是唯一的。",
"documentSettings.title": "文件設定",
"editPipelineInfo": "編輯管線資訊",
"editPipelineInfoNameRequired": "請輸入知識庫的名稱。",
"exportDSL.errorTip": "無法匯出管線 DSL",
"exportDSL.successTip": "成功匯出管線 DSL",
"inputField": "輸入欄位",