Merge branch 'main' into jzh

This commit is contained in:
JzoNg 2026-04-28 16:34:20 +08:00
commit e48f13f173
37 changed files with 1092 additions and 591 deletions

View File

@ -110,6 +110,28 @@ jobs:
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
# hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
# to return (container processes started); it does not wait on healthcheck
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
# wait the migration runs while InnoDB is still initialising and gets
# killed with "Lost connection during query". Poll a real SELECT until it
# succeeds.
- name: Wait for MySQL to accept queries
run: |
set +e
for i in $(seq 1 60); do
if docker run --rm --network host mysql:8.0 \
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
-e 'SELECT 1' >/dev/null 2>&1; then
echo "MySQL ready after ${i}s"
exit 0
fi
sleep 1
done
echo "MySQL not ready after 60s; dumping container logs:"
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
exit 1
- name: Run DB Migration
env:
DEBUG: true

View File

@ -13,7 +13,7 @@ concurrency:
jobs:
test:
name: Web Full-Stack E2E
runs-on: depot-ubuntu-24.04
runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash

View File

@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
return documents_and_batch_fields, 200
def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
"""Update a document from an uploaded file for canonical and deprecated routes."""
dataset_id_str = str(dataset_id)
tenant_id_str = str(tenant_id)
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if dataset.provider == "external":
raise ValueError("External datasets are not supported.")
args: dict[str, object] = {}
if "data" in request.form:
args = json.loads(request.form["data"])
if "doc_form" not in args:
args["doc_form"] = dataset.chunk_structure or "text_model"
if "doc_language" not in args:
args["doc_language"] = "English"
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if "file" in request.files:
# save file info
file = request.files["file"]
if len(request.files) > 1:
raise TooManyFilesError()
if not file.filename:
raise FilenameNotExistsError
if not current_user:
raise ValueError("current_user is required")
try:
upload_file = FileService(db.engine).upload_file(
filename=file.filename,
content=file.read(),
mimetype=file.mimetype,
user=current_user,
source="datasets",
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, _ = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=dataset.created_by_account,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
return documents_and_batch_fields, 200
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update_by_file",
"/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/update-by-file",
)
class DocumentUpdateByFileApi(DatasetApiResource):
"""Resource for update documents."""
class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
"""Deprecated resource aliases for file document updates."""
@service_api_ns.doc("update_document_by_file")
@service_api_ns.doc(description="Update an existing document by uploading a file")
@service_api_ns.doc("update_document_by_file_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
description=(
"Deprecated legacy alias for updating an existing document by uploading a file. "
"Use PATCH /datasets/{dataset_id}/documents/{document_id} instead."
)
)
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Update document by upload file."""
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
if dataset.provider == "external":
raise ValueError("External datasets are not supported.")
args = {}
if "data" in request.form:
args = json.loads(request.form["data"])
if "doc_form" not in args:
args["doc_form"] = dataset.chunk_structure or "text_model"
if "doc_language" not in args:
args["doc_language"] = "English"
# get dataset info
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
if "file" in request.files:
# save file info
file = request.files["file"]
if len(request.files) > 1:
raise TooManyFilesError()
if not file.filename:
raise FilenameNotExistsError
if not current_user:
raise ValueError("current_user is required")
try:
upload_file = FileService(db.engine).upload_file(
filename=file.filename,
content=file.read(),
mimetype=file.mimetype,
user=current_user,
source="datasets",
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description)
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
data_source = {
"type": "upload_file",
"info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
}
args["data_source"] = data_source
# validate args
args["original_document_id"] = str(document_id)
knowledge_config = KnowledgeConfig.model_validate(args)
DocumentService.document_create_args_validate(knowledge_config)
try:
documents, _ = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=dataset.created_by_account,
dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
created_from="api",
)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
document = documents[0]
documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
return documents_and_batch_fields, 200
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by file through the deprecated file-update aliases."""
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
return response
@service_api_ns.doc("update_document_by_file")
@service_api_ns.doc(description="Update an existing document by uploading a file")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
401: "Unauthorized - invalid API token",
404: "Document not found",
}
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by file on the canonical document resource."""
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.doc("delete_document")
@service_api_ns.doc(description="Delete a document")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})

View File

@ -23,6 +23,7 @@ from werkzeug.exceptions import Forbidden, NotFound
from controllers.service_api.dataset.document import (
DeprecatedDocumentAddByTextApi,
DeprecatedDocumentUpdateByFileApi,
DeprecatedDocumentUpdateByTextApi,
DocumentAddByFileApi,
DocumentAddByTextApi,
@ -32,7 +33,6 @@ from controllers.service_api.dataset.document import (
DocumentListQuery,
DocumentTextCreatePayload,
DocumentTextUpdate,
DocumentUpdateByFileApi,
DocumentUpdateByTextApi,
InvalidMetadataError,
)
@ -1095,8 +1095,8 @@ class TestArchivedDocumentImmutableError:
assert error.code == 403
class TestDocumentTextRouteDeprecation:
"""Test that legacy underscore text routes stay marked deprecated."""
class TestDocumentRouteDeprecation:
"""Test that legacy document routes stay marked deprecated."""
def test_create_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy create-by-text alias is marked deprecated."""
@ -1108,10 +1108,15 @@ class TestDocumentTextRouteDeprecation:
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
def test_update_by_file_legacy_aliases_are_deprecated(self):
"""Ensure only the legacy file-update aliases are marked deprecated."""
assert DeprecatedDocumentUpdateByFileApi.post.__apidoc__["deprecated"] is True
assert DocumentApi.patch.__apidoc__.get("deprecated") is not True
# =============================================================================
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
# DocumentUpdateByFileApi.
# and the canonical/deprecated document file update routes.
#
# These controllers use ``@cloud_edition_billing_resource_check`` (does NOT
# preserve ``__wrapped__``) and ``@cloud_edition_billing_rate_limit_check``
@ -1359,13 +1364,52 @@ class TestDocumentAddByFileApiPost:
api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id)
class TestDocumentUpdateByFileApiPost:
"""Test suite for DocumentUpdateByFileApi.post() endpoint.
class TestDocumentUpdateByFileApiPatch:
"""Test suite for the canonical document file update endpoint.
``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
``patch`` is wrapped by ``@cloud_edition_billing_resource_check`` and
``@cloud_edition_billing_rate_limit_check``.
"""
@pytest.mark.parametrize("route_name", ["update_by_file", "update-by-file"])
@patch("controllers.service_api.dataset.document._update_document_by_file")
@patch("controllers.service_api.wraps.FeatureService")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
def test_update_by_file_deprecated_aliases_delegate_to_shared_handler(
self,
mock_validate_token,
mock_feature_svc,
mock_update_document_by_file,
route_name,
app,
mock_tenant,
mock_dataset,
):
"""Test legacy POST aliases still dispatch while marked deprecated."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_update_document_by_file.return_value = ({"document": {"id": "doc-1"}, "batch": "batch-1"}, 200)
doc_id = str(uuid.uuid4())
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/{route_name}",
method="POST",
headers={"Authorization": "Bearer test_token"},
):
api = DeprecatedDocumentUpdateByFileApi()
response, status = api.post(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
)
assert status == 200
assert response["batch"] == "batch-1"
mock_update_document_by_file.assert_called_once_with(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
)
@patch("controllers.service_api.dataset.document.db")
@patch("controllers.service_api.wraps.FeatureService")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@ -1387,15 +1431,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
method="POST",
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
api = DocumentUpdateByFileApi()
api = DocumentApi()
with pytest.raises(ValueError, match="Dataset does not exist"):
api.post(
api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
@ -1423,15 +1467,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
method="POST",
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
api = DocumentUpdateByFileApi()
api = DocumentApi()
with pytest.raises(ValueError, match="External datasets"):
api.post(
api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
@ -1482,14 +1526,14 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")}
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
method="POST",
f"/datasets/{mock_dataset.id}/documents/{doc_id}",
method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
api = DocumentUpdateByFileApi()
response, status = api.post(
api = DocumentApi()
response, status = api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,

View File

@ -5,12 +5,13 @@ from baidubce.auth.bce_credentials import BceCredentials
from baidubce.bce_client_configuration import BceClientConfiguration
from extensions.storage.baidu_obs_storage import BaiduObsStorage
from tests.unit_tests.oss.__mock.baidu_obs import setup_baidu_obs_mock
from tests.unit_tests.oss.__mock.base import (
BaseStorageTest,
get_example_bucket,
)
pytest_plugins = ("tests.unit_tests.oss.__mock.baidu_obs",)
class TestBaiduObs(BaseStorageTest):
@pytest.fixture(autouse=True)

View File

@ -59,19 +59,25 @@ services:
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
ports:
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
# mysqladmin ping passes during mysql:8.0's TCP-listening stage even while
# the server is still finalising init, leading to "Lost connection during
# query" on the first real query. Verify with a real SELECT instead.
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-u",
"root",
"mysql",
"-h",
"127.0.0.1",
"-uroot",
"-p${DB_PASSWORD:-difyai123456}",
"-e",
"SELECT 1",
]
interval: 1s
timeout: 3s
retries: 30
start_period: 20s
# The redis cache.
redis:

View File

@ -17,3 +17,10 @@ Feature: Share app publicly
Given a workflow app has been published and shared via API
When I open the shared app URL
Then the shared app page should be accessible
@unauthenticated
Scenario: Run a shared workflow app without authentication
Given a workflow app has been published and shared via API
When I open the shared app URL
And I run the shared workflow app
Then the shared workflow run should succeed

View File

@ -37,3 +37,15 @@ Then('the shared app page should be accessible', async function (this: DifyWorld
await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 })
await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 })
})
When('I run the shared workflow app', async function (this: DifyWorld) {
const page = this.getPage()
const runButton = page.getByTestId('run-button')
await expect(runButton).toBeEnabled({ timeout: 15_000 })
await runButton.click()
})
Then('the shared workflow run should succeed', async function (this: DifyWorld) {
await expect(this.getPage().getByTestId('status-icon-success')).toBeVisible({ timeout: 55_000 })
})

View File

@ -12,8 +12,10 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage()
await page.getByText('Test Run').click()
await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 })
const testRunButton = page.getByText('Test Run')
await expect(testRunButton).toBeVisible({ timeout: 15_000 })
await testRunButton.click()
})
Then('the workflow run should succeed', async function (this: DifyWorld) {

View File

@ -3506,11 +3506,6 @@
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/strategy-picker.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/reference-setting-modal/auto-update-setting/types.ts": {
"erasable-syntax-only/enums": {
"count": 2

View File

@ -532,6 +532,7 @@ describe('useEmbeddedChatbot', () => {
})
it('handleChangeConversation updates current conversation and refetches chat list', async () => {
mockStoreState.embeddedConversationId = null
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
act(() => {
@ -548,6 +549,39 @@ describe('useEmbeddedChatbot', () => {
expect(result.current.clearChatList).toBe(false)
})
// Scenario: URL-provided conversation_id should take precedence over localStorage value.
it('should prioritize URL conversation_id over localStorage', async () => {
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({
'app-1': { 'embedded-user-1': 'stored-conv-id' },
}))
mockStoreState.embeddedConversationId = 'url-conv-id'
mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({
user_id: 'embedded-user-1',
conversation_id: 'url-conv-id',
})
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => {
expect(result.current.currentConversationId).toBe('url-conv-id')
})
})
// Scenario: When no URL conversation_id is provided, fall back to localStorage.
it('should fall back to localStorage when no URL conversation_id is provided', async () => {
localStorage.setItem(CONVERSATION_ID_INFO, JSON.stringify({
'app-1': { DEFAULT: 'stored-conv-id' },
}))
mockStoreState.embeddedConversationId = null
mockStoreState.embeddedUserId = null
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => {
expect(result.current.currentConversationId).toBe('stored-conv-id')
})
})
it('handleFeedback invokes updateFeedback service successfully', async () => {
const { updateFeedback } = await import('@/service/share')
const { result } = await renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))

View File

@ -113,7 +113,7 @@ export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: stri
})
}, [setConversationIdInfo])
const allowResetChat = !conversationId
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '', [appId, conversationIdInfo, userId, conversationId])
const currentConversationId = useMemo(() => conversationId || conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId, conversationId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
if (appId) {
let prevValue = conversationIdInfo?.[appId || '']

View File

@ -43,7 +43,7 @@ describe('OptionListItem', () => {
</OptionListItem>,
)
const item = screen.getByRole('listitem')
const item = screen.getByRole('button')
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
})
@ -54,7 +54,7 @@ describe('OptionListItem', () => {
</OptionListItem>,
)
const item = screen.getByRole('listitem')
const item = screen.getByRole('button')
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
})
})
@ -100,7 +100,7 @@ describe('OptionListItem', () => {
Clickable
</OptionListItem>,
)
fireEvent.click(screen.getByRole('listitem'))
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
@ -111,7 +111,7 @@ describe('OptionListItem', () => {
Item
</OptionListItem>,
)
fireEvent.click(screen.getByRole('listitem'))
fireEvent.click(screen.getByRole('button'))
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
})
@ -126,7 +126,7 @@ describe('OptionListItem', () => {
</OptionListItem>,
)
const item = screen.getByRole('listitem')
const item = screen.getByRole('button')
fireEvent.click(item)
fireEvent.click(item)
fireEvent.click(item)

View File

@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react'
import OptionList from '../option-list'
describe('OptionList', () => {
it('should render a scrollable list with hidden scrollbar styles', () => {
render(
<OptionList>
<li>Item</li>
</OptionList>,
)
const list = screen.getByRole('list')
expect(list).toHaveClass('overflow-y-auto')
expect(list).toHaveClass('[scrollbar-width:none]')
expect(list).toHaveClass('[&::-webkit-scrollbar]:hidden')
})
it('should append caller className after default classes', () => {
render(
<OptionList className="custom-list">
<li>Item</li>
</OptionList>,
)
expect(screen.getByRole('list')).toHaveClass('custom-list')
})
})

View File

@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
import { useEffect, useRef } from 'react'
@ -7,7 +7,8 @@ type OptionListItemProps = {
isSelected: boolean
onClick: () => void
noAutoScroll?: boolean
} & React.LiHTMLAttributes<HTMLLIElement>
children: ReactNode
}
const OptionListItem: FC<OptionListItemProps> = ({
isSelected,
@ -25,16 +26,21 @@ const OptionListItem: FC<OptionListItemProps> = ({
return (
<li
ref={listItemRef}
className={cn(
'flex cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text',
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
)}
onClick={() => {
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
onClick()
}}
>
{children}
<button
type="button"
className={cn(
'flex w-full cursor-pointer items-center justify-center rounded-md px-1.5 py-1 system-xs-medium text-components-button-ghost-text outline-hidden',
'focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:ring-inset',
isSelected ? 'bg-components-button-ghost-bg-hover' : 'hover:bg-components-button-ghost-bg-hover',
)}
onClick={() => {
listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
onClick()
}}
>
{children}
</button>
</li>
)
}

View File

@ -0,0 +1,26 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type OptionListProps = {
children: ReactNode
} & HTMLAttributes<HTMLUListElement>
const optionListClassName = cn(
'flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
)
const OptionList = ({
children,
className,
...props
}: OptionListProps) => {
return (
<ul className={cn(optionListClassName, className)} {...props}>
{children}
</ul>
)
}
export default React.memo(OptionList)

View File

@ -64,13 +64,13 @@ describe('TimePickerOptions', () => {
it('should render selected hour in the list', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />)
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
const selectedHour = screen.getAllByRole('button').find(item => item.textContent === '05')
expect(selectedHour)!.toHaveClass('bg-components-button-ghost-bg-hover')
})
it('should render selected minute in the list', () => {
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
render(<Options {...props} />)
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
const selectedMinute = screen.getAllByRole('button').find(item => item.textContent === '30')
expect(selectedMinute)!.toHaveClass('bg-components-button-ghost-bg-hover')
})

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { TimeOptionsProps } from '../types'
import * as React from 'react'
import OptionList from '../common/option-list'
import OptionListItem from '../common/option-list-item'
import { useTimeOptions } from '../hooks'
@ -16,7 +17,7 @@ const Options: FC<TimeOptionsProps> = ({
return (
<div className="grid grid-cols-3 gap-x-1 p-2">
{/* Hour */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
<OptionList>
{
hourOptions.map((hour) => {
const isSelected = selectedTime?.format('hh') === hour
@ -31,9 +32,9 @@ const Options: FC<TimeOptionsProps> = ({
)
})
}
</ul>
</OptionList>
{/* Minute */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
<OptionList>
{
(minuteFilter ? minuteFilter(minuteOptions) : minuteOptions).map((minute) => {
const isSelected = selectedTime?.format('mm') === minute
@ -48,9 +49,9 @@ const Options: FC<TimeOptionsProps> = ({
)
})
}
</ul>
</OptionList>
{/* Period */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
<OptionList>
{
periodOptions.map((period) => {
const isSelected = selectedTime?.format('A') === period
@ -66,7 +67,7 @@ const Options: FC<TimeOptionsProps> = ({
)
})
}
</ul>
</OptionList>
</div>
)
}

View File

@ -1,4 +1,4 @@
import type { Placement } from '@floating-ui/react'
import type { Placement } from '@langgenius/dify-ui/popover'
import type { Dayjs } from 'dayjs'
export enum ViewType {

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { YearAndMonthPickerOptionsProps } from '../types'
import * as React from 'react'
import OptionList from '../common/option-list'
import OptionListItem from '../common/option-list-item'
import { useMonths, useYearOptions } from '../hooks'
@ -16,7 +17,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
return (
<div className="grid grid-cols-2 gap-x-1 p-2">
{/* Month Picker */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
<OptionList>
{
months.map((month, index) => {
const isSelected = selectedMonth === index
@ -31,9 +32,9 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
)
})
}
</ul>
</OptionList>
{/* Year Picker */}
<ul className="scrollbar-none flex h-[208px] flex-col gap-y-0.5 overflow-y-auto pb-[184px]">
<OptionList>
{
yearOptions.map((year) => {
const isSelected = selectedYear === year
@ -48,7 +49,7 @@ const Options: FC<YearAndMonthPickerOptionsProps> = ({
)
})
}
</ul>
</OptionList>
</div>
)
}

View File

@ -42,7 +42,6 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({
placement="bottom-start"
sideOffset={4}
popupClassName="w-max"
listClassName="no-scrollbar"
>
{supportedLanguages.map(({ prompt_name }) => (
<SelectItem key={prompt_name} value={prompt_name}>

View File

@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
})
vi.mock('../popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
default: ({ model }: { model: Model }) => (
<div>
<span>{model.provider}</span>
{model.models.map(modelItem => (
<span key={modelItem.model}>{modelItem.model}</span>
))}
</div>
),
}))
vi.mock('@/context/provider-context', () => ({
@ -207,6 +214,156 @@ describe('Popup', () => {
expect((input as HTMLInputElement).value).toBe('')
})
it('should show matching models when searching by model name', () => {
renderPopup(
<Popup
modelList={[
makeModel({
models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'claude' } },
)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument()
expect(screen.getByText('claude-3')).toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
expect(screen.queryByText('No model found for \u201Cclaude\u201D')).not.toBeInTheDocument()
})
it('should show empty search placeholder when no provider or model name matches', () => {
renderPopup(
<Popup
modelList={[
makeModel({
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'mistral' } },
)
expect(screen.getByText('No model found for \u201Cmistral\u201D'))!.toBeInTheDocument()
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
})
it('should show all models of a provider when searching by provider label', () => {
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
makeModelItem({ model: 'gpt-4o', label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' } }),
],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('openai'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
})
it('should match by model provider key when model label does not contain the search text', () => {
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'azure_openai',
label: { en_US: 'Azure', zh_Hans: 'Azure' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('azure_openai'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
})
it('should still apply scope features when matching by provider label', () => {
mockSupportFunctionCall.mockReturnValue(false)
renderPopup(
<Popup
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', features: [ModelFeatureEnum.vision] }),
makeModelItem({ model: 'gpt-4-tool', features: [ModelFeatureEnum.toolCall] }),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.toolCall]}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('No model found for \u201Copenai\u201D'))!.toBeInTheDocument()
expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument()
})
it('should not show compatible-only helper text when no scope features are applied', () => {
renderPopup(
<Popup
@ -219,8 +376,8 @@ describe('Popup', () => {
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
})
it('should show compatible-only helper banner when scope features are applied', () => {
const { container } = renderPopup(
it('should show compatible-only helper text when scope features are applied', () => {
renderPopup(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -231,7 +388,26 @@ describe('Popup', () => {
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument()
expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument()
})
it('should keep search and footer outside the scrollable model list', () => {
renderPopup(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' })
const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel')
const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ })
expect(scrollRegion)!.toBeInTheDocument()
expect(scrollRegion).not.toContainElement(searchInput)
expect(scrollRegion).not.toContainElement(settingsButton)
expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner'))
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {

View File

@ -88,7 +88,7 @@ const ModelSelector: FC<ModelSelectorProps> = ({
placement="bottom-start"
sideOffset={4}
className={popupClassName}
popupClassName="overflow-hidden rounded-lg"
popupClassName="overflow-hidden rounded-xl"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
>
<Popup

View File

@ -0,0 +1,98 @@
import type { FC } from 'react'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { getMarketplaceUrl } from '@/utils/var'
import { modelNameMap, providerIconMap } from '../utils'
type MarketplaceSectionProps = {
marketplaceProviders: ModelProviderQuotaGetPaid[]
marketplaceCollapsed: boolean
installingProvider: ModelProviderQuotaGetPaid | null
isMarketplacePluginsLoading: boolean
theme?: string
onMarketplaceCollapsedChange: (collapsed: boolean) => void
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
}
const MarketplaceSection: FC<MarketplaceSectionProps> = ({
marketplaceProviders,
marketplaceCollapsed,
installingProvider,
isMarketplacePluginsLoading,
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
}) => {
const { t } = useTranslation()
if (marketplaceProviders.length === 0)
return null
return (
<>
<div className="py-2">
<div className="h-px bg-divider-subtle" />
</div>
<div>
<div className="flex h-[22px] items-center pr-2 pl-4">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<div className="px-1 pb-1">
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => onInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 py-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
</a>
</div>
)}
</div>
</>
)
}
export default MarketplaceSection

View File

@ -0,0 +1,39 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
type ModelSelectorEmptyStateProps = {
onConfigure: () => void
}
const ModelSelectorEmptyState: FC<ModelSelectorEmptyStateProps> = ({
onConfigure,
}) => {
const { t } = useTranslation()
return (
<div className="mx-2 flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="system-xs-regular text-text-tertiary">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={onConfigure}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)
}
export default ModelSelectorEmptyState

View File

@ -107,7 +107,8 @@ const PopupItem: FC<PopupItemProps> = ({
return (
<div className="mb-1">
<div className="sticky top-12 z-2 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
{/* Keep the sticky provider header above model rows while the list scrolls. */}
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center"
onClick={() => setCollapsed(prev => !prev)}

View File

@ -0,0 +1,130 @@
import type { FC, ReactNode } from 'react'
import {
ScrollAreaContent,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
type ModelSelectorPopupFrameProps = {
children: ReactNode
}
export const ModelSelectorPopupFrame: FC<ModelSelectorPopupFrameProps> = ({
children,
}) => {
return (
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
{children}
</div>
)
}
type ModelSelectorSearchHeaderProps = {
searchText: string
onSearchTextChange: (value: string) => void
}
export const ModelSelectorSearchHeader: FC<ModelSelectorSearchHeaderProps> = ({
searchText,
onSearchTextChange,
}) => {
const { t } = useTranslation()
return (
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
<div className={`
flex h-8 items-center rounded-lg border px-2
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
className={`
mr-0.5 i-ri-search-line h-4 w-4 shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className="block h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => onSearchTextChange(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => onSearchTextChange('')}
/>
)
}
</div>
</div>
)
}
type ModelSelectorScrollBodyProps = {
children: ReactNode
label: string
}
export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
children,
label,
}) => {
return (
<ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain">
<ScrollAreaViewport
aria-label={label}
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overscroll-contain"
role="region"
>
<ScrollAreaContent className="min-w-0">
{children}
</ScrollAreaContent>
</ScrollAreaViewport>
{/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
)
}
export const CompatibleModelsNotice = () => {
const { t } = useTranslation()
return (
<div
data-testid="compatible-models-banner"
className="px-4 py-2 system-xs-regular text-text-tertiary"
>
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</div>
)
}
type ModelProviderSettingsFooterProps = {
onOpenSettings: () => void
}
export const ModelProviderSettingsFooter: FC<ModelProviderSettingsFooterProps> = ({
onOpenSettings,
}) => {
const { t } = useTranslation()
return (
<div className="shrink-0 border-t border-divider-subtle p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
onClick={onOpenSettings}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</button>
</div>
)
}

View File

@ -5,8 +5,6 @@ import type {
ModelItem,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
import MarketplaceSection from './marketplace-section'
import ModelSelectorEmptyState from './popup-empty-state'
import PopupItem from './popup-item'
import {
CompatibleModelsNotice,
ModelProviderSettingsFooter,
ModelSelectorPopupFrame,
ModelSelectorScrollBody,
ModelSelectorSearchHeader,
} from './popup-layout'
type PopupProps = {
defaultModel?: DefaultModel
@ -137,18 +143,26 @@ const Popup: FC<PopupProps> = ({
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
const normalizedSearch = searchText.toLowerCase()
const matchesLabel = (label: Record<string, string>) => {
if (label[language] !== undefined)
return label[language].toLowerCase().includes(normalizedSearch)
return Object.values(label).some(value =>
value.toLowerCase().includes(normalizedSearch),
)
}
const filtered = installedModelList.map((model) => {
const matchesProviderSearch = !searchText
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
const providerMatched = !!searchText && (
matchesLabel(model.label)
|| model.provider.toLowerCase().includes(normalizedSearch)
)
const filteredModels = model.models
.filter((modelItem) => {
if (modelItem.label[language] !== undefined)
return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
return Object.values(modelItem.label).some(label =>
label.toLowerCase().includes(searchText.toLowerCase()),
)
if (!searchText || providerMatched)
return true
return matchesLabel(modelItem.label)
})
.filter((modelItem) => {
if (scopeFeatures.length === 0)
@ -159,8 +173,12 @@ const Popup: FC<PopupProps> = ({
return modelItem.features?.includes(feature) ?? false
})
})
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
if (
(searchText && filteredModels.length === 0)
|| (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
) {
return null
}
return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null)
@ -181,166 +199,59 @@ const Popup: FC<PopupProps> = ({
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelProviders])
const handleOpenSettings = useCallback(() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}, [onHide, setShowAccountSettingModal])
return (
<div className="no-scrollbar max-h-[480px] overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg pt-3 pr-2 pb-1 pl-3">
<div className={`
flex h-8 items-center rounded-lg border pr-[10px] pl-[9px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<span
className={`
mr-[7px] i-ri-search-line h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
<input
className="block h-[18px] grow appearance-none bg-transparent text-[13px] text-text-primary outline-hidden"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => setSearchText(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => setSearchText('')}
/>
)
}
</div>
{scopeFeatures.length > 0 && (
<div
data-testid="compatible-models-banner"
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
>
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
<p className="system-xs-medium text-text-secondary">
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</p>
</div>
)}
</div>
<ModelSelectorPopupFrame>
<ModelSelectorSearchHeader
searchText={searchText}
onSearchTextChange={setSearchText}
/>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="pr-1 pb-1 pl-3">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleOpenSettings}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<div className="flex flex-col gap-2 rounded-[10px] bg-linear-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
<div className="flex flex-col gap-1">
<p className="system-sm-medium text-text-secondary">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="system-xs-regular text-text-tertiary">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
)}
{marketplaceProviders.length > 0 && (
<>
<div className="mx-2 my-1 border-t border-divider-subtle" />
<div className="mb-1">
<div className="flex h-[22px] items-center px-3">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
onClick={() => setMarketplaceCollapsed(prev => !prev)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</div>
{!marketplaceCollapsed && (
<>
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pr-0.5 pl-3 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="system-sm-regular text-text-secondary">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => handleInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 system-xs-regular text-text-accent">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line h-3! w-3! text-text-accent" />
</a>
</>
)}
</div>
</>
)}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</div>
</div>
)}
{scopeFeatures.length > 0 && (
<CompatibleModelsNotice />
)}
<MarketplaceSection
marketplaceProviders={marketplaceProviders}
marketplaceCollapsed={marketplaceCollapsed}
installingProvider={installingProvider}
isMarketplacePluginsLoading={isMarketplacePluginsLoading}
theme={theme}
onMarketplaceCollapsedChange={setMarketplaceCollapsed}
onInstallPlugin={handleInstallPlugin}
/>
</div>
</ModelSelectorScrollBody>
<ModelProviderSettingsFooter onOpenSettings={handleOpenSettings} />
</ModelSelectorPopupFrame>
)
}

View File

@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import PluginItem from './plugin-item'
type PluginSectionProps = {
@ -43,7 +44,14 @@ const PluginSection: FC<PluginSectionProps> = ({
)
{headerAction}
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={title}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{plugins.map(plugin => (
<PluginItem
key={plugin.plugin_unique_identifier}
@ -59,7 +67,7 @@ const PluginSection: FC<PluginSectionProps> = ({
: undefined}
/>
))}
</div>
</ScrollArea>
</>
)
}

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
import ErrorPluginItem from './error-plugin-item'
@ -86,7 +87,14 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
{t('task.clearAll', { ns: 'plugin' })}
</Button>
</div>
<div className="max-h-[300px] overflow-x-hidden overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<ScrollArea
className="max-h-[300px] overflow-hidden"
label={t('task.installedError', { ns: 'plugin', errorLength: errorPlugins.length })}
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-w-0',
}}
>
{errorPlugins.map(plugin => (
<ErrorPluginItem
key={plugin.plugin_unique_identifier}
@ -96,7 +104,7 @@ const PluginTaskList: FC<PluginTaskListProps> = ({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
</div>
</ScrollArea>
</>
)}
</div>

View File

@ -117,7 +117,7 @@ const PluginTasks = () => {
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="[scrollbar-width:none] overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none [&::-webkit-scrollbar]:hidden"
popupClassName="overflow-visible border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<PluginTaskList
runningPlugins={runningPlugins}

View File

@ -2,6 +2,7 @@ import type { AutoUpdateConfig } from '../types'
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
@ -803,165 +804,103 @@ describe('auto-update-setting', () => {
})
describe('StrategyPicker (strategy-picker.tsx)', () => {
const defaultProps = {
value: AUTO_UPDATE_STRATEGY.disabled,
onChange: vi.fn(),
const i18nKeyByStrategy: Record<AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest'> = {
[AUTO_UPDATE_STRATEGY.disabled]: 'disabled',
[AUTO_UPDATE_STRATEGY.fixOnly]: 'fixOnly',
[AUTO_UPDATE_STRATEGY.latest]: 'latest',
}
const triggerName = (strategy: AUTO_UPDATE_STRATEGY) =>
new RegExp(`plugin\\.autoUpdate\\.strategy\\.${i18nKeyByStrategy[strategy]}\\.name`, 'i')
const findOption = async (key: 'disabled' | 'fixOnly' | 'latest') => {
const options = await screen.findAllByRole('menuitemradio')
const option = options.find(item =>
item.textContent?.includes(`plugin.autoUpdate.strategy.${key}.name`),
)
if (!option)
throw new Error(`Strategy option "${key}" not found`)
return option
}
describe('Rendering', () => {
it('should render trigger button with current strategy label', () => {
// Act
render(<StrategyPicker {...defaultProps} value={AUTO_UPDATE_STRATEGY.disabled} />)
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert
expect(screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.disabled\.name/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })).toBeInTheDocument()
})
it('should not render dropdown content when closed', () => {
// Act
render(<StrategyPicker {...defaultProps} />)
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('should render all strategy options when open', () => {
// Arrange
mockPortalOpen = true
it('should render all strategy options when open', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Act
render(<StrategyPicker {...defaultProps} />)
fireEvent.click(screen.getByTestId('portal-trigger'))
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) }))
// Wait for portal to open
if (mockPortalOpen) {
// Assert all options visible (use getAllByText for strategy name as it appears in both trigger and dropdown)
expect(screen.getAllByText('plugin.autoUpdate.strategy.disabled.name').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('plugin.autoUpdate.strategy.fixOnly.name')).toBeInTheDocument()
expect(screen.getByText('plugin.autoUpdate.strategy.latest.name')).toBeInTheDocument()
}
const options = await screen.findAllByRole('menuitemradio')
expect(options).toHaveLength(3)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.disabled.name'))).toBe(true)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.fixOnly.name'))).toBe(true)
expect(options.some(o => o.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))).toBe(true)
})
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
// Act
render(<StrategyPicker {...defaultProps} />)
// Assert - initially closed
expect(mockPortalOpen).toBe(false)
// Act - click trigger
fireEvent.click(screen.getByTestId('portal-trigger'))
// Assert - portal trigger element should still be in document
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
})
it('should call onChange with fixOnly when Bug Fixes Only option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
// Find and click the "Bug Fixes Only" option
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
expect(fixOnlyOption).toBeInTheDocument()
fireEvent.click(fixOnlyOption!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
})
it('should call onChange with latest when Latest Version option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />)
// Find and click the "Latest Version" option
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
expect(latestOption).toBeInTheDocument()
fireEvent.click(latestOption!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
})
it('should call onChange with disabled when Disabled option is clicked', () => {
// Arrange - force portal content to be visible for testing option selection
forcePortalContentVisible = true
const onChange = vi.fn()
// Act
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={onChange} />)
// Find and click the "Disabled" option - need to find the one in the dropdown, not the button
const disabledOptions = screen.getAllByText('plugin.autoUpdate.strategy.disabled.name')
// The second one should be in the dropdown
const dropdownOption = disabledOptions.find(el => el.closest('div[class*="cursor-pointer"]'))
expect(dropdownOption).toBeInTheDocument()
fireEvent.click(dropdownOption!.closest('div[class*="cursor-pointer"]')!)
// Assert
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.disabled)
})
it('should stop event propagation when option is clicked', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
const onChange = vi.fn()
const parentClickHandler = vi.fn()
// Act
render(
<div onClick={parentClickHandler}>
<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={onChange} />
</div>,
)
// Click an option
const fixOnlyOption = screen.getByText('plugin.autoUpdate.strategy.fixOnly.name').closest('div[class*="cursor-pointer"]')
fireEvent.click(fixOnlyOption!)
// Assert - onChange is called but parent click handler should not propagate
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.fixOnly)
})
it('should render check icon for currently selected option', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
// Act - render with fixOnly selected
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
// Assert - RiCheckLine should be rendered (check icon)
// Find all "Bug Fixes Only" texts and get the one in the dropdown (has cursor-pointer parent)
const allFixOnlyTexts = screen.getAllByText('plugin.autoUpdate.strategy.fixOnly.name')
const dropdownOption = allFixOnlyTexts.find(el => el.closest('div[class*="cursor-pointer"]'))
const optionContainer = dropdownOption?.closest('div[class*="cursor-pointer"]')
expect(optionContainer).toBeInTheDocument()
// The check icon SVG should exist within the option
expect(optionContainer?.querySelector('svg')).toBeInTheDocument()
})
it('should not render check icon for non-selected options', () => {
// Arrange - force portal content to be visible
forcePortalContentVisible = true
// Act - render with disabled selected
it('should open and close the menu when the trigger is clicked', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.disabled} onChange={vi.fn()} />)
// Assert - check the Latest Version option should not have check icon
const latestOption = screen.getByText('plugin.autoUpdate.strategy.latest.name').closest('div[class*="cursor-pointer"]')
// The svg should only be in selected option, not in non-selected
const checkIconContainer = latestOption?.querySelector('div.mr-1')
// Non-selected option should have empty check icon container
expect(checkIconContainer?.querySelector('svg')).toBeNull()
const trigger = screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.disabled) })
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
await user.click(trigger)
expect(await screen.findByRole('menu')).toBeInTheDocument()
})
it.each<[AUTO_UPDATE_STRATEGY, 'disabled' | 'fixOnly' | 'latest', AUTO_UPDATE_STRATEGY]>([
[AUTO_UPDATE_STRATEGY.disabled, 'fixOnly', AUTO_UPDATE_STRATEGY.fixOnly],
[AUTO_UPDATE_STRATEGY.disabled, 'latest', AUTO_UPDATE_STRATEGY.latest],
[AUTO_UPDATE_STRATEGY.fixOnly, 'disabled', AUTO_UPDATE_STRATEGY.disabled],
])('should call onChange with %s -> %s when option is selected', async (initial, optionKey, expected) => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<StrategyPicker value={initial} onChange={onChange} />)
await user.click(screen.getByRole('button', { name: triggerName(initial) }))
await user.click(await findOption(optionKey))
expect(onChange).toHaveBeenCalledWith(expected)
})
it('should mark only the currently selected option with aria-checked', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
const options = await screen.findAllByRole('menuitemradio')
const checked = options.filter(o => o.getAttribute('aria-checked') === 'true')
expect(checked).toHaveLength(1)
expect(checked[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
})
it('should render the check indicator inside the selected option only', async () => {
const user = userEvent.setup()
render(<StrategyPicker value={AUTO_UPDATE_STRATEGY.fixOnly} onChange={vi.fn()} />)
await user.click(screen.getByRole('button', { name: triggerName(AUTO_UPDATE_STRATEGY.fixOnly) }))
const fixOnlyOption = await findOption('fixOnly')
const latestOption = await findOption('latest')
expect(fixOnlyOption.querySelector('.i-ri-check-line')).toBeInTheDocument()
expect(latestOption.querySelector('.i-ri-check-line')).toBeNull()
})
})
})
@ -1280,7 +1219,9 @@ describe('auto-update-setting', () => {
render(<AutoUpdateSetting {...defaultProps} />)
// Assert
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
).toBeInTheDocument()
})
it('should show time picker when strategy is not disabled', () => {
@ -1407,16 +1348,27 @@ describe('auto-update-setting', () => {
})
describe('User Interactions', () => {
it('should call onChange with updated strategy when strategy changes', () => {
it('should call onChange with updated strategy when strategy changes', async () => {
// Arrange
const user = userEvent.setup()
const onChange = vi.fn()
const payload = createMockAutoUpdateConfig()
const payload = createMockAutoUpdateConfig({ strategy_setting: AUTO_UPDATE_STRATEGY.fixOnly })
// Act
render(<AutoUpdateSetting payload={payload} onChange={onChange} />)
// Assert - component renders with strategy picker
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: /plugin\.autoUpdate\.strategy\.fixOnly\.name/i }),
)
const latestOption = (await screen.findAllByRole('menuitemradio')).find(item =>
item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'),
)!
await user.click(latestOption)
// Assert
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ strategy_setting: AUTO_UPDATE_STRATEGY.latest }),
)
})
it('should call onChange with updated time when time changes', () => {

View File

@ -1,62 +1,12 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import StrategyPicker from '../strategy-picker'
import { AUTO_UPDATE_STRATEGY } from '../types'
let portalOpen = false
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({
children,
}: {
children: React.ReactNode
}) => <span data-testid="picker-button">{children}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const _React = await import('react')
return {
PortalToFollowElem: ({
open,
children,
}: {
open: boolean
children: React.ReactNode
}) => {
portalOpen = open
return <div>{children}</div>
},
PortalToFollowElemTrigger: ({
children,
onClick,
}: {
children: React.ReactNode
onClick: (event: { stopPropagation: () => void, nativeEvent: { stopImmediatePropagation: () => void } }) => void
}) => (
<button
data-testid="trigger"
onClick={() => onClick({
stopPropagation: vi.fn(),
nativeEvent: { stopImmediatePropagation: vi.fn() },
})}
>
{children}
</button>
),
PortalToFollowElemContent: ({
children,
}: {
children: React.ReactNode
}) => portalOpen ? <div data-testid="portal-content">{children}</div> : null,
}
})
const triggerName = (key: string) => new RegExp(`plugin\\.autoUpdate\\.strategy\\.${key}\\.name`, 'i')
describe('StrategyPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
portalOpen = false
})
it('renders the selected strategy label in the trigger', () => {
render(
<StrategyPicker
@ -65,10 +15,12 @@ describe('StrategyPicker', () => {
/>,
)
expect(screen.getByTestId('trigger')).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
expect(screen.getByRole('button', { name: triggerName('fixOnly') })).toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('opens the option list when the trigger is clicked', () => {
it('opens the option list when the trigger is clicked', async () => {
const user = userEvent.setup()
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.disabled}
@ -76,14 +28,33 @@ describe('StrategyPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('portal-content').querySelectorAll('svg')).toHaveLength(1)
const options = await screen.findAllByRole('menuitemradio')
expect(options).toHaveLength(3)
expect(screen.getByText('plugin.autoUpdate.strategy.latest.description')).toBeInTheDocument()
})
it('calls onChange when a new strategy is selected', () => {
it('marks only the currently selected strategy as checked', async () => {
const user = userEvent.setup()
render(
<StrategyPicker
value={AUTO_UPDATE_STRATEGY.fixOnly}
onChange={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: triggerName('fixOnly') }))
const checkedOptions = (await screen.findAllByRole('menuitemradio'))
.filter(item => item.getAttribute('aria-checked') === 'true')
expect(checkedOptions).toHaveLength(1)
expect(checkedOptions[0]).toHaveTextContent('plugin.autoUpdate.strategy.fixOnly.name')
})
it('calls onChange and closes the menu when a new strategy is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(
<StrategyPicker
@ -92,9 +63,12 @@ describe('StrategyPicker', () => {
/>,
)
fireEvent.click(screen.getByTestId('trigger'))
fireEvent.click(screen.getByText('plugin.autoUpdate.strategy.latest.name'))
await user.click(screen.getByRole('button', { name: triggerName('disabled') }))
const latestOption = (await screen.findAllByRole('menuitemradio'))
.find(item => item.textContent?.includes('plugin.autoUpdate.strategy.latest.name'))!
await user.click(latestOption)
expect(onChange).toHaveBeenCalledWith(AUTO_UPDATE_STRATEGY.latest)
expect(await screen.findByRole('button', { name: triggerName('disabled') })).toBeInTheDocument()
})
})

View File

@ -105,7 +105,7 @@ const AutoUpdateSetting: FC<Props> = ({
const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => {
return (
<div
className="group float-right flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt"
className="group flex h-8 w-[160px] cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal px-2 hover:bg-state-base-hover-alt"
onClick={onClick}
>
<div className="flex w-0 grow items-center gap-x-1">
@ -137,7 +137,7 @@ const AutoUpdateSetting: FC<Props> = ({
<>
<div className="flex items-center justify-between">
<Label label={t(`${i18nPrefix}.updateTime`, { ns: 'plugin' })} />
<div className="flex flex-col justify-start">
<div className="flex flex-col items-end">
<TimePicker
value={timeOfDayToDayjs(convertUTCDaySecondsToLocalSeconds(upgrade_time_of_day, timezone!))}
timezone={timezone}

View File

@ -1,15 +1,14 @@
import { Button } from '@langgenius/dify-ui/button'
import {
RiArrowDownSLine,
RiCheckLine,
} from '@remixicon/react'
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuRadioItemIndicator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { AUTO_UPDATE_STRATEGY } from './types'
const i18nPrefix = 'autoUpdate.strategy'
@ -42,58 +41,48 @@ const StrategyPicker = ({
},
]
const selectedOption = options.find(option => option.value === value)
const handleValueChange = (nextValue: string) => {
onChange(nextValue as AUTO_UPDATE_STRATEGY)
setOpen(false)
}
return (
<PortalToFollowElem
<DropdownMenu
open={open}
onOpenChange={setOpen}
placement="top-end"
offset={4}
>
<PortalToFollowElemTrigger onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
setOpen(v => !v)
}}
<DropdownMenuTrigger render={<Button size="small" />}>
{selectedOption?.label}
<span aria-hidden className="i-ri-arrow-down-s-line h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="top-end"
sideOffset={4}
className="z-99"
popupClassName="w-[280px] p-1"
>
<Button
size="small"
<DropdownMenuRadioGroup
value={value}
onValueChange={handleValueChange}
>
{selectedOption?.label}
<RiArrowDownSLine className="h-3.5 w-3.5" />
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-99">
<div className="w-[280px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg">
{
options.map(option => (
<div
key={option.value}
className="flex cursor-pointer rounded-lg p-2 pr-3 hover:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
onChange(option.value)
setOpen(false)
}}
>
<div className="mr-1 w-4 shrink-0">
{
value === option.value && (
<RiCheckLine className="h-4 w-4 text-text-accent" />
)
}
</div>
<div className="grow">
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
</div>
{options.map(option => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
className="mx-0 h-auto items-start gap-1 p-2 pr-3"
>
<div className="mr-1 flex w-4 shrink-0 justify-center pt-0.5">
<DropdownMenuRadioItemIndicator className="ml-0" />
</div>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className="grow">
<div className="mb-0.5 system-sm-semibold text-text-secondary">{option.label}</div>
<div className="system-xs-regular text-text-tertiary">{option.description}</div>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -49,17 +49,17 @@ export const ChecklistNodeGroup = memo(({
<div
key={sub.key}
className={cn(
'group/item flex items-center gap-2 rounded-lg px-1',
'group/item flex items-start gap-2 rounded-lg px-1',
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={() => goToEnabled && onItemClick(item)}
>
<ItemIndicator />
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
<span className="min-w-0 grow py-1 text-xs leading-4 text-text-warning">
{sub.message}
</span>
{goToEnabled && (
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
<div className="flex shrink-0 items-center gap-0.5 pt-1 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
<span className="text-xs leading-4 font-medium whitespace-nowrap text-text-accent">
{t('panel.goToFix', { ns: 'workflow' })}
</span>

View File

@ -5,7 +5,7 @@ import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import RemoveButton from '@/app/components/workflow/nodes/_base/components/remove-button'
import ActionButton from '@/app/components/base/action-button'
import VariableTypeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/variable-type-select'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
@ -116,7 +116,7 @@ const ObjectValueItem: FC<Props> = ({
{/* Value */}
<div className="relative w-[230px]">
<input
className="block h-7 w-full appearance-none px-2 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
className="block h-7 w-full appearance-none px-2 pr-9 system-xs-regular text-text-secondary caret-primary-600 outline-hidden placeholder:system-xs-regular placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
placeholder={t('chatVariable.modal.objectValue', { ns: 'workflow' }) || ''}
value={list[index].value}
onChange={handleValueChange(index)}
@ -125,10 +125,15 @@ const ObjectValueItem: FC<Props> = ({
type={list[index].type === ChatVarType.Number ? 'number' : 'text'}
/>
{list.length > 1 && !isFocus && (
<RemoveButton
className="absolute top-0.5 right-1 z-10 hidden group-hover:block"
onClick={handleItemRemove(index)}
/>
<div className="absolute top-0.5 right-1 z-10">
<ActionButton
size="m"
className="group hover:bg-state-destructive-hover!"
onClick={handleItemRemove(index)}
>
<span className="i-ri-delete-bin-line h-4 w-4 text-text-tertiary group-hover:text-text-destructive" />
</ActionButton>
</div>
)}
</div>
</div>