Compare commits

...

6 Commits

Author SHA1 Message Date
9d401442a3 fix: fix Working outside of application context (#35819)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-06 10:51:10 +08:00
29388b2a89 fix: fix structured_output_enabled miss in second validate (#35747)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai>
2026-05-06 09:38:45 +08:00
9d96e6e520 fix: var reference picker can not choose sub vars (#35732)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 16:55:21 +08:00
yyh
842110a601 refactor(web): migrate subscription create modal to dialog (#35721)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 13:48:40 +08:00
abcfb5bb81 refactor(auth): update OAuth button and settings modal for improved state management and UI consistency (#35702)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-30 00:04:49 +08:00
7113b42744 fix(publisher): enhance confirm dialog handling and improve popup interactions (#35701)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-04-30 00:04:47 +08:00
23 changed files with 996 additions and 260 deletions

View File

@ -9,9 +9,9 @@ from typing import TYPE_CHECKING, Any
from pydantic import TypeAdapter
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from core.entities.model_entities import DefaultModelEntity, DefaultModelProviderEntity
from core.entities.provider_configuration import ProviderConfiguration, ProviderConfigurations, ProviderModelBundle
from core.entities.provider_entities import (
@ -445,7 +445,7 @@ class ProviderManager:
@staticmethod
def _get_all_providers(tenant_id: str) -> dict[str, list[Provider]]:
provider_name_to_provider_records_dict = defaultdict(list)
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(Provider).where(Provider.tenant_id == tenant_id, Provider.is_valid == True)
providers = session.scalars(stmt)
for provider in providers:
@ -462,7 +462,7 @@ class ProviderManager:
:return:
"""
provider_name_to_provider_model_records_dict = defaultdict(list)
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(ProviderModel).where(ProviderModel.tenant_id == tenant_id, ProviderModel.is_valid == True)
provider_models = session.scalars(stmt)
for provider_model in provider_models:
@ -478,7 +478,7 @@ class ProviderManager:
:return:
"""
provider_name_to_preferred_provider_type_records_dict = {}
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(TenantPreferredModelProvider).where(TenantPreferredModelProvider.tenant_id == tenant_id)
preferred_provider_types = session.scalars(stmt)
provider_name_to_preferred_provider_type_records_dict = {
@ -496,7 +496,7 @@ class ProviderManager:
:return:
"""
provider_name_to_provider_model_settings_dict = defaultdict(list)
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(ProviderModelSetting).where(ProviderModelSetting.tenant_id == tenant_id)
provider_model_settings = session.scalars(stmt)
for provider_model_setting in provider_model_settings:
@ -514,7 +514,7 @@ class ProviderManager:
:return:
"""
provider_name_to_provider_model_credentials_dict = defaultdict(list)
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(ProviderModelCredential).where(ProviderModelCredential.tenant_id == tenant_id)
provider_model_credentials = session.scalars(stmt)
for provider_model_credential in provider_model_credentials:
@ -544,7 +544,7 @@ class ProviderManager:
return {}
provider_name_to_provider_load_balancing_model_configs_dict = defaultdict(list)
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = select(LoadBalancingModelConfig).where(LoadBalancingModelConfig.tenant_id == tenant_id)
provider_load_balancing_configs = session.scalars(stmt)
for provider_load_balancing_config in provider_load_balancing_configs:
@ -578,7 +578,7 @@ class ProviderManager:
:param provider_name: provider name
:return:
"""
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = (
select(ProviderCredential)
.where(
@ -608,7 +608,7 @@ class ProviderManager:
:param model_type: model type
:return:
"""
with Session(db.engine, expire_on_commit=False) as session:
with session_factory.create_session() as session:
stmt = (
select(ProviderModelCredential)
.where(

View File

@ -365,7 +365,8 @@ class DifyNodeFactory(NodeFactory):
(including pydantic ValidationError, which subclasses ValueError),
if node type is unknown, or if no implementation exists for the resolved version
"""
typed_node_config = NodeConfigDictAdapter.validate_python(adapt_node_config_for_graph(node_config))
adapted_node_config = adapt_node_config_for_graph(node_config)
typed_node_config = NodeConfigDictAdapter.validate_python(adapted_node_config)
node_id = typed_node_config["id"]
node_data = typed_node_config["data"]
node_class = self._resolve_node_class(node_type=node_data.type, node_version=str(node_data.version))
@ -373,6 +374,11 @@ class DifyNodeFactory(NodeFactory):
# Re-validate using the resolved node class so workflow-local node schemas
# stay explicit and constructors receive the concrete typed payload.
resolved_node_data = self._validate_resolved_node_data(node_class, node_data)
config_for_node_init: BaseNodeData | dict[str, Any]
if isinstance(resolved_node_data, BaseNodeData):
config_for_node_init = resolved_node_data.model_dump(mode="python", by_alias=True)
else:
config_for_node_init = resolved_node_data
node_type = node_data.type
node_init_kwargs_factories: Mapping[NodeType, Callable[[], dict[str, object]]] = {
BuiltinNodeTypes.CODE: lambda: {
@ -442,7 +448,7 @@ class DifyNodeFactory(NodeFactory):
node_init_kwargs = node_init_kwargs_factories.get(node_type, lambda: {})()
return node_class(
node_id=node_id,
config=resolved_node_data,
config=config_for_node_init,
graph_init_params=self.graph_init_params,
graph_runtime_state=self.graph_runtime_state,
**node_init_kwargs,
@ -474,10 +480,7 @@ class DifyNodeFactory(NodeFactory):
include_retriever_attachment_loader: bool,
include_jinja2_template_renderer: bool,
) -> dict[str, object]:
validated_node_data = cast(
LLMCompatibleNodeData,
self._validate_resolved_node_data(node_class=node_class, node_data=node_data),
)
validated_node_data = cast(LLMCompatibleNodeData, node_data)
model_instance = self._build_model_instance_for_llm_node(validated_node_data)
node_init_kwargs: dict[str, object] = {
"credentials_provider": self._llm_credentials_provider,

View File

@ -9,11 +9,11 @@ import sqlalchemy as sa
from sqlalchemy import DateTime, String, func, select, text
from sqlalchemy.orm import Mapped, mapped_column
from core.db.session_factory import session_factory
from graphon.model_runtime.entities.model_entities import ModelType
from libs.uuid_utils import uuidv7
from .base import TypeBase
from .engine import db
from .enums import CredentialSourceType, PaymentStatus, ProviderQuotaType
from .types import EnumText, LongText, StringUUID
@ -82,7 +82,8 @@ class Provider(TypeBase):
@cached_property
def credential(self):
if self.credential_id:
return db.session.scalar(select(ProviderCredential).where(ProviderCredential.id == self.credential_id))
with session_factory.create_session() as session:
return session.scalar(select(ProviderCredential).where(ProviderCredential.id == self.credential_id))
@property
def credential_name(self):
@ -145,9 +146,10 @@ class ProviderModel(TypeBase):
@cached_property
def credential(self):
if self.credential_id:
return db.session.scalar(
select(ProviderModelCredential).where(ProviderModelCredential.id == self.credential_id)
)
with session_factory.create_session() as session:
return session.scalar(
select(ProviderModelCredential).where(ProviderModelCredential.id == self.credential_id)
)
@property
def credential_name(self):

View File

@ -570,8 +570,7 @@ def test_get_all_providers_normalizes_provider_names_with_model_provider_id() ->
session.scalars.return_value = [openai_provider, gemini_provider]
with (
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = ProviderManager._get_all_providers("tenant-id")
@ -595,8 +594,7 @@ def test_provider_grouping_helpers_group_records_by_provider_name(method_name: s
session.scalars.return_value = [openai_primary, openai_secondary, anthropic_record]
with (
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = getattr(ProviderManager, method_name)("tenant-id")
@ -611,8 +609,7 @@ def test_get_all_preferred_model_providers_returns_mapping_by_provider_name() ->
session.scalars.return_value = [openai_preference, anthropic_preference]
with (
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = ProviderManager._get_all_preferred_model_providers("tenant-id")
@ -626,13 +623,13 @@ def test_get_all_provider_load_balancing_configs_returns_empty_when_cached_flag_
with (
patch("core.provider_manager.redis_client.get", return_value=b"False"),
patch("core.provider_manager.FeatureService.get_features") as mock_get_features,
patch("core.provider_manager.Session") as mock_session_cls,
patch("core.provider_manager.session_factory.create_session") as mock_create_session,
):
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")
assert result == {}
mock_get_features.assert_not_called()
mock_session_cls.assert_not_called()
mock_create_session.assert_not_called()
def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_configs() -> None:
@ -642,14 +639,13 @@ def test_get_all_provider_load_balancing_configs_populates_cache_and_groups_conf
session.scalars.return_value = [openai_config, anthropic_config]
with (
patch("core.provider_manager.db", SimpleNamespace(engine=object())),
patch("core.provider_manager.redis_client.get", return_value=None),
patch("core.provider_manager.redis_client.setex") as mock_setex,
patch(
"core.provider_manager.FeatureService.get_features",
return_value=SimpleNamespace(model_load_balancing_enabled=True),
),
patch("core.provider_manager.Session", return_value=_build_session_context(session)),
patch("core.provider_manager.session_factory.create_session", return_value=_build_session_context(session)),
):
result = ProviderManager._get_all_provider_load_balancing_configs("tenant-id")

View File

@ -5,6 +5,7 @@ from graphon.graph_events import (
NodeRunStreamChunkEvent,
)
from .test_mock_config import MockConfigBuilder
from .test_table_runner import TableTestRunner
@ -44,3 +45,51 @@ def test_tool_in_chatflow():
assert stream_chunk_events[0].chunk == "hello, dify!", (
f"Expected chunk to be 'hello, dify!', but got {stream_chunk_events[0].chunk}"
)
def test_answer_can_render_llm_structured_output_in_chatflow():
runner = TableTestRunner()
fixture_data = runner.workflow_runner.load_fixture("basic_chatflow")
nodes = fixture_data["workflow"]["graph"]["nodes"]
answer_node = next(node for node in nodes if node["id"] == "answer")
answer_node["data"]["answer"] = "{{#llm.structured_output#}}"
mock_config = (
MockConfigBuilder()
.with_node_output(
"llm",
{
"text": "plain text",
"structured_output": {"type": "greeting"},
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
"finish_reason": "stop",
},
)
.build()
)
graph, graph_runtime_state = runner.workflow_runner.create_graph_from_fixture(
fixture_data=fixture_data,
query="hello",
use_mock_factory=True,
mock_config=mock_config,
)
engine = GraphEngine(
workflow_id="test_workflow",
graph=graph,
graph_runtime_state=graph_runtime_state,
command_channel=InMemoryChannel(),
config=GraphEngineConfig(),
)
events = list(engine.run())
success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)]
assert success_events, "Workflow should complete successfully"
assert success_events[-1].outputs["answer"] == '{\n "type": "greeting"\n}'

View File

@ -86,3 +86,80 @@ def test_execute_answer():
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == "Today's weather is sunny\nYou are a helpful AI.\n{{img}}\nFin."
def test_execute_answer_renders_structured_output_object_as_json() -> None:
init_params = build_test_graph_init_params(
workflow_id="1",
graph_config={"nodes": [], "edges": []},
tenant_id="1",
app_id="1",
user_id="1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=0,
)
variable_pool = VariablePool(
system_variables=build_system_variables(user_id="aaa", files=[]),
user_inputs={},
environment_variables=[],
conversation_variables=[],
)
variable_pool.add(["1777539038857", "structured_output"], {"type": "greeting"})
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
node = AnswerNode(
node_id=str(uuid.uuid4()),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
config=AnswerNodeData(
title="123",
type="answer",
answer="{{#1777539038857.structured_output#}}",
),
)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == '{\n "type": "greeting"\n}'
def test_execute_answer_falls_back_to_plain_selector_text_when_structured_output_missing() -> None:
init_params = build_test_graph_init_params(
workflow_id="1",
graph_config={"nodes": [], "edges": []},
tenant_id="1",
app_id="1",
user_id="1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
call_depth=0,
)
variable_pool = VariablePool(
system_variables=build_system_variables(user_id="aaa", files=[]),
user_inputs={},
environment_variables=[],
conversation_variables=[],
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
node = AnswerNode(
node_id=str(uuid.uuid4()),
graph_init_params=init_params,
graph_runtime_state=graph_runtime_state,
config=AnswerNodeData(
title="123",
type="answer",
answer="{{#1777539038857.structured_output#}}",
),
)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["answer"] == "1777539038857.structured_output"

View File

@ -10,14 +10,20 @@ from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.nodes.code.entities import CodeLanguage
from graphon.nodes.llm.entities import LLMNodeData
from graphon.variables.segments import StringSegment
def _assert_typed_node_config(config, *, node_id: str, node_type: NodeType, version: str = "1") -> None:
_ = node_id
assert isinstance(config, BaseNodeData)
assert config.type == node_type
assert config.version == version
if isinstance(config, BaseNodeData):
assert config.type == node_type
assert config.version == version
return
assert isinstance(config, dict)
assert config["type"] == node_type
assert config["version"] == version
def _node_constructor(*, return_value):
@ -546,6 +552,84 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config
assert kwargs["http_client"] is sentinel.http_client
def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory):
node_data = LLMNodeData.model_validate(
{
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
"structured_output_enabled": True,
"structured_output": {
"schema": {
"type": "object",
"properties": {"type": {"type": "string"}},
"required": ["type"],
}
},
}
)
wrapped_model_instance = sentinel.wrapped_model_instance
memory = sentinel.memory
factory._build_model_instance_for_llm_node = MagicMock(return_value=sentinel.model_instance)
factory._build_memory_for_llm_node = MagicMock(return_value=memory)
with patch.object(node_factory, "DifyPreparedLLM", return_value=wrapped_model_instance) as prepared_llm:
kwargs = factory._build_llm_compatible_node_init_kwargs(
node_class=sentinel.node_class,
node_data=node_data,
wrap_model_instance=True,
include_http_client=True,
include_llm_file_saver=True,
include_prompt_message_serializer=True,
include_retriever_attachment_loader=True,
include_jinja2_template_renderer=True,
)
assert node_data.structured_output_switch_on is True
assert node_data.structured_output_enabled is True
factory._build_model_instance_for_llm_node.assert_called_once_with(node_data)
factory._build_memory_for_llm_node.assert_called_once_with(
node_data=node_data,
model_instance=sentinel.model_instance,
)
prepared_llm.assert_called_once_with(sentinel.model_instance)
assert kwargs["model_instance"] is wrapped_model_instance
def test_create_node_passes_alias_preserving_llm_config_to_constructor(self, monkeypatch, factory):
created_node = object()
constructor = _node_constructor(return_value=created_node)
monkeypatch.setattr(factory, "_resolve_node_class", MagicMock(return_value=constructor))
monkeypatch.setattr(factory, "_build_llm_compatible_node_init_kwargs", MagicMock(return_value={}))
node_config = {
"id": "llm-node-id",
"data": {
"type": BuiltinNodeTypes.LLM,
"title": "LLM",
"model": {"provider": "provider", "name": "model", "mode": "chat", "completion_params": {}},
"prompt_template": [{"role": "system", "text": "x"}],
"context": {"enabled": False, "variable_selector": []},
"vision": {"enabled": False},
"structured_output_enabled": True,
"structured_output": {
"schema": {
"type": "object",
"properties": {"type": {"type": "string"}},
"required": ["type"],
}
},
},
}
factory.create_node(node_config)
config = constructor.call_args.kwargs["config"]
assert isinstance(config, dict)
assert config["structured_output_enabled"] is True
assert "structured_output_switch_on" not in config
@pytest.mark.parametrize(
("node_type", "constructor_name", "expected_extra_kwargs"),
[

View File

@ -1825,11 +1825,6 @@
"count": 4
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/prompt-editor/plugins/component-picker-block/menu.tsx": {
"erasable-syntax-only/parameter-properties": {
"count": 1
@ -3203,24 +3198,11 @@
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorize/add-oauth-button.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/plugin-auth/authorize/index.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorize/oauth-client-settings.tsx": {
"no-restricted-imports": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}
},
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -3344,11 +3326,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": {
"erasable-syntax-only/enums": {
"count": 1

View File

@ -29,6 +29,7 @@ import {
} from 'lexical'
import * as React from 'react'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { VarType } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -928,5 +929,46 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
vi.useRealTimers()
})
it('does not hide the menu when focus moves into a variable child popup', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
render((
<MinimalEditor
triggerString="/"
workflowVariableBlock={makeWorkflowVariableBlock({}, [
makeWorkflowVarNode('node-1', 'Node 1', [
makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child', VarType.string)]),
]),
])}
captures={captures}
/>
))
const editor = await waitForEditor(captures)
await setEditorText(editor, '/', true)
expect(await screen.findByText('payload')).toBeInTheDocument()
vi.useFakeTimers()
const popupTarget = document.createElement('button')
const popup = document.createElement('div')
popup.classList.add(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME)
popup.appendChild(popupTarget)
document.body.appendChild(popup)
act(() => {
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur-sm', { relatedTarget: popupTarget }))
})
act(() => {
vi.advanceTimersByTime(200)
})
expect(screen.queryByText('payload')).toBeInTheDocument()
popup.remove()
vi.useRealTimers()
})
})
})

View File

@ -14,6 +14,7 @@ import type {
WorkflowVariableBlockType,
} from '../../types'
import type { PickerBlockMenuOption } from './menu'
import type { EventEmitterValue } from '@/context/event-emitter'
import {
flip,
offset,
@ -39,7 +40,7 @@ import {
} from 'react'
import ReactDOM from 'react-dom'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import VarReferenceVars, { VAR_REFERENCE_CHILD_POPUP_CLASS_NAME } from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { $splitNodeContainingQuery } from '../../utils'
@ -119,7 +120,9 @@ const ComponentPicker = ({
(event) => {
clearBlurTimer()
const target = event?.relatedTarget as HTMLElement
if (!target?.classList?.contains('var-search-input'))
const isVariableMenuTarget = target?.classList?.contains('var-search-input')
|| target?.closest?.(`.${VAR_REFERENCE_CHILD_POPUP_CLASS_NAME}`)
if (!isVariableMenuTarget)
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
return false
},
@ -143,8 +146,8 @@ const ComponentPicker = ({
}
}, [editor, clearBlurTimer])
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
eventEmitter?.useSubscription((v: EventEmitterValue) => {
if (typeof v !== 'string' && v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND && typeof v.payload === 'string')
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
})
@ -303,7 +306,7 @@ const ComponentPicker = ({
}
</>
)
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, triggerString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin

View File

@ -1,10 +1,14 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
import type { FormSchema } from '@/app/components/base/form/types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthCategory } from '../../types'
const mockGetPluginOAuthUrl = vi.fn().mockResolvedValue({ authorization_url: 'https://auth.example.com' })
const mockOpenOAuthPopup = vi.fn()
const mockWriteText = vi.fn()
const mockOAuthClientSettingsProps: OAuthClientSettingsProps[] = []
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string> | string) => typeof obj === 'string' ? obj : obj.en_US || '',
@ -31,11 +35,37 @@ vi.mock('../../hooks/use-credential', () => ({
}))
vi.mock('../oauth-client-settings', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div data-testid="oauth-settings-modal">
<button data-testid="oauth-settings-close" onClick={onClose}>Close</button>
</div>
),
default: (props: OAuthClientSettingsProps) => {
mockOAuthClientSettingsProps.push(props)
const {
open = true,
onClose,
onOpenChange,
schemas,
} = props
if (!open)
return null
const handleClose = () => {
onOpenChange?.(false)
onClose?.()
}
return (
<div data-testid="oauth-settings-modal">
<button data-testid="oauth-settings-close" onClick={handleClose}>Close</button>
{schemas.map(schema => (
<div key={schema.name} data-testid={`oauth-schema-${schema.name}`}>
<div data-testid={`oauth-schema-label-${schema.name}`}>
{React.isValidElement(schema.label) ? schema.label : String(schema.label || '')}
</div>
{String(schema.default || '')}
</div>
))}
</div>
)
},
}))
vi.mock('@/app/components/base/form/types', () => ({
@ -56,6 +86,11 @@ describe('AddOAuthButton', () => {
beforeEach(async () => {
vi.clearAllMocks()
mockOAuthClientSettingsProps.length = 0
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: mockWriteText },
})
const mod = await import('../add-oauth-button')
AddOAuthButton = mod.default
})
@ -72,6 +107,7 @@ describe('AddOAuthButton', () => {
fireEvent.click(screen.getByTestId('oauth-settings-button'))
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
expect(mockOAuthClientSettingsProps.at(-1)?.open).toBe(true)
})
it('should close OAuth settings modal', () => {
@ -84,13 +120,37 @@ describe('AddOAuthButton', () => {
})
it('should trigger OAuth flow on main button click', async () => {
const mockOnUpdate = vi.fn()
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" onUpdate={mockOnUpdate} />)
const button = screen.getByText('Use OAuth').closest('button')
if (button)
fireEvent.click(button)
await waitFor(() => {
expect(mockOpenOAuthPopup).toHaveBeenCalledWith('https://auth.example.com', expect.any(Function))
})
const handleOAuthSuccess = mockOpenOAuthPopup.mock.calls[0]?.[1]
expect(handleOAuthSuccess).toBeTypeOf('function')
if (typeof handleOAuthSuccess === 'function')
handleOAuthSuccess()
expect(mockOnUpdate).toHaveBeenCalled()
})
it('should not open OAuth popup when authorization URL is missing', async () => {
mockGetPluginOAuthUrl.mockResolvedValueOnce({})
render(<AddOAuthButton pluginPayload={basePayload} buttonText="Use OAuth" />)
const button = screen.getByText('Use OAuth').closest('button')
if (button)
fireEvent.click(button)
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
await waitFor(() => {
expect(mockGetPluginOAuthUrl).toHaveBeenCalled()
})
expect(mockOpenOAuthPopup).not.toHaveBeenCalled()
})
it('should be disabled when disabled prop is true', () => {
@ -99,4 +159,96 @@ describe('AddOAuthButton', () => {
const button = screen.getByText('Use OAuth').closest('button')
expect(button).toBeDisabled()
})
it('should open OAuth settings from setup entry when OAuth is not configured', () => {
render(
<AddOAuthButton
pluginPayload={basePayload}
oAuthData={{
schema: [],
is_oauth_custom_client_enabled: false,
is_system_oauth_params_exists: false,
client_params: {},
}}
/>,
)
fireEvent.click(screen.getByText('plugin.auth.setupOAuth'))
expect(screen.getByTestId('oauth-settings-modal')).toBeInTheDocument()
expect(mockOAuthClientSettingsProps.at(-1)?.editValues).toMatchObject({
__oauth_client__: 'custom',
})
})
it('should show custom badge when OAuth custom client is enabled', () => {
render(
<AddOAuthButton
pluginPayload={basePayload}
buttonText="Use OAuth"
oAuthData={{
schema: [],
is_oauth_custom_client_enabled: true,
is_system_oauth_params_exists: true,
client_params: {},
}}
/>,
)
expect(screen.getByText('plugin.auth.custom')).toBeInTheDocument()
})
it('should build custom OAuth schema and edit values for settings modal', () => {
const schema = [
{
name: 'client_id',
label: { en_US: 'Client ID' },
type: 'text-input',
required: true,
default: 'schema-client-id',
},
] as FormSchema[]
render(
<AddOAuthButton
pluginPayload={basePayload}
buttonText="Use OAuth"
oAuthData={{
schema,
is_oauth_custom_client_enabled: true,
is_system_oauth_params_exists: true,
client_params: { client_id: 'stored-client-id' },
redirect_uri: 'https://redirect.example.com',
}}
/>,
)
fireEvent.click(screen.getByTestId('oauth-settings-button'))
const settingsProps = mockOAuthClientSettingsProps.at(-1)
expect(settingsProps?.editValues).toMatchObject({
__oauth_client__: 'custom',
client_id: 'stored-client-id',
})
expect(settingsProps?.hasOriginalClientParams).toBe(true)
expect(settingsProps?.schemas[0]).toMatchObject({
name: '__oauth_client__',
default: 'custom',
})
expect(settingsProps?.schemas[1]).toMatchObject({
name: 'client_id',
default: 'stored-client-id',
show_on: [
{
variable: '__oauth_client__',
value: 'custom',
},
],
})
expect(screen.getByText('https://redirect.example.com')).toBeInTheDocument()
fireEvent.click(within(screen.getByTestId('oauth-schema-label-client_id')).getByRole('button'))
expect(mockWriteText).toHaveBeenCalledWith('https://redirect.example.com')
})
})

View File

@ -1,5 +1,6 @@
import type { ApiKeyModalProps } from '../api-key-modal'
import type { FormSchema } from '@/app/components/base/form/types'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -384,6 +385,29 @@ describe('ApiKeyModal', () => {
expect(mockOnClose).toHaveBeenCalled()
})
it('should close on backdrop click when nested inside another dialog', async () => {
const mockOnClose = vi.fn()
render(
<Dialog open>
<DialogContent backdropClassName="bg-transparent">
<ControlledModalHarness ApiKeyModal={ApiKeyModal} onClose={mockOnClose} />
</DialogContent>
</Dialog>,
)
const backdrop = document.querySelector('.bg-background-overlay')
expect(backdrop).toBeInTheDocument()
fireEvent.pointerDown(backdrop!)
fireEvent.mouseDown(backdrop!)
fireEvent.click(backdrop!)
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
})
it('should render readme entrance when detail is provided', () => {
const payload = { ...basePayload, detail: { name: 'Test' } as never }
render(<ApiKeyModal pluginPayload={payload} />)

View File

@ -1,4 +1,8 @@
import type { OAuthClientSettingsProps } from '../oauth-client-settings'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AuthCategory } from '../../types'
@ -20,7 +24,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockSetPluginOAuthCustomClient = vi.fn().mockResolvedValue({})
const mockDeletePluginOAuthCustomClient = vi.fn().mockResolvedValue({})
const mockInvalidPluginOAuthClientSchema = vi.fn()
const mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
let mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
let mockAuthFormProps: Record<string, unknown> | undefined
vi.mock('../../hooks/use-credential', () => ({
useSetPluginOAuthCustomClientHook: () => ({
@ -40,36 +45,19 @@ vi.mock('../../../readme-panel/store', () => ({
ReadmeShowType: { modal: 'modal' },
}))
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({ children, title, onClose: _onClose, onConfirm, onCancel, onExtraButtonClick, footerSlot }: {
children: React.ReactNode
title: string
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
onExtraButtonClick?: () => void
footerSlot?: React.ReactNode
[key: string]: unknown
}) => (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-confirm" onClick={onConfirm}>Save And Auth</button>
<button data-testid="modal-cancel" onClick={onCancel}>Save Only</button>
<button data-testid="modal-close" onClick={onExtraButtonClick}>Cancel</button>
{!!footerSlot && <div data-testid="footer-slot">{footerSlot}</div>}
</div>
),
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((_props: Record<string, unknown>, ref: React.Ref<unknown>) => {
vi.mock('@/app/components/base/form/form-scenarios/auth', () => {
const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref<unknown> } & Record<string, unknown>) => {
mockAuthFormProps = props
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormValues,
}))
return <div data-testid="auth-form" />
}),
}))
}
return {
default: MockAuthForm,
}
})
vi.mock('@tanstack/react-form', () => ({
useForm: (config: Record<string, unknown>) => ({
@ -89,11 +77,72 @@ const defaultSchemas = [
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true },
] as never
const PopoverSettingsHarness = ({
OAuthClientSettings,
onClose,
onPopoverClose,
}: {
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
onClose: () => void
onPopoverClose: () => void
}) => {
const [open, setOpen] = React.useState(true)
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen)
if (!nextOpen)
onPopoverClose()
}}
>
<PopoverTrigger render={<button type="button">OAuth</button>} />
<PopoverContent>
<div data-testid="oauth-popover">
<OAuthClientSettings
open={open}
onOpenChange={setOpen}
pluginPayload={basePayload}
schemas={defaultSchemas}
onClose={onClose}
/>
</div>
</PopoverContent>
</Popover>
)
}
const ControlledSettingsHarness = ({
OAuthClientSettings,
onClose,
}: {
OAuthClientSettings: React.FC<OAuthClientSettingsProps>
onClose: () => void
}) => {
const [open, setOpen] = React.useState(true)
return (
<>
<div data-testid="modal-open-state">{String(open)}</div>
<OAuthClientSettings
open={open}
onOpenChange={setOpen}
pluginPayload={basePayload}
schemas={defaultSchemas}
onClose={onClose}
/>
</>
)
}
describe('OAuthClientSettings', () => {
let OAuthClientSettings: (typeof import('../oauth-client-settings'))['default']
let OAuthClientSettings: React.FC<OAuthClientSettingsProps>
beforeEach(async () => {
vi.clearAllMocks()
mockFormValues = { isCheckValidated: true, values: { __oauth_client__: 'custom', client_id: 'test-id' } }
mockAuthFormProps = undefined
const mod = await import('../oauth-client-settings')
OAuthClientSettings = mod.default
})
@ -120,6 +169,36 @@ describe('OAuthClientSettings', () => {
expect(screen.getByTestId('auth-form')).toBeInTheDocument()
})
it('should render backdrop when nested inside another dialog', () => {
render(
<Dialog open>
<DialogContent backdropClassName="bg-transparent">
<OAuthClientSettings
pluginPayload={basePayload}
schemas={defaultSchemas}
/>
</DialogContent>
</Dialog>,
)
expect(document.querySelector('.bg-background-overlay')).toBeInTheDocument()
})
it('should pass schema defaults to auth form', () => {
render(
<OAuthClientSettings
pluginPayload={basePayload}
schemas={[
{ name: 'client_id', label: 'Client ID', type: 'text-input', required: true, default: 'default-client-id' },
] as never}
/>,
)
expect(mockAuthFormProps?.defaultValues).toMatchObject({
client_id: 'default-client-id',
})
})
it('should call onClose when cancel clicked', () => {
const mockOnClose = vi.fn()
render(
@ -134,6 +213,33 @@ describe('OAuthClientSettings', () => {
expect(mockOnClose).toHaveBeenCalled()
})
it('should close through controlled open state when cancel clicked', async () => {
const mockOnClose = vi.fn()
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
fireEvent.click(screen.getByTestId('modal-close'))
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
})
it('should close when backdrop is clicked', async () => {
const mockOnClose = vi.fn()
render(<ControlledSettingsHarness OAuthClientSettings={OAuthClientSettings} onClose={mockOnClose} />)
const backdrop = document.querySelector('.bg-background-overlay')
expect(backdrop).toBeInTheDocument()
fireEvent.click(backdrop!)
await waitFor(() => {
expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false')
})
expect(mockOnClose).toHaveBeenCalled()
})
it('should save settings on save only button click', async () => {
const mockOnClose = vi.fn()
const mockOnUpdate = vi.fn()
@ -155,6 +261,38 @@ describe('OAuthClientSettings', () => {
})
})
it('should ignore duplicate save clicks while action is pending', async () => {
const mockOnClose = vi.fn()
let resolveSave: (value: object) => void = () => {}
mockSetPluginOAuthCustomClient.mockImplementationOnce(() => new Promise((resolve) => {
resolveSave = resolve
}))
render(
<OAuthClientSettings
pluginPayload={basePayload}
schemas={defaultSchemas}
onClose={mockOnClose}
/>,
)
fireEvent.click(screen.getByTestId('modal-cancel'))
await waitFor(() => {
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(mockSetPluginOAuthCustomClient).toHaveBeenCalledTimes(1)
resolveSave({})
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled()
})
})
it('should save and authorize on confirm button click', async () => {
const mockOnAuth = vi.fn().mockResolvedValue(undefined)
render(
@ -172,6 +310,34 @@ describe('OAuthClientSettings', () => {
})
})
it('should remove custom client settings', async () => {
const mockOnClose = vi.fn()
const mockOnUpdate = vi.fn()
render(
<OAuthClientSettings
pluginPayload={basePayload}
schemas={defaultSchemas}
editValues={{ client_id: 'test-id' }}
hasOriginalClientParams
onClose={mockOnClose}
onUpdate={mockOnUpdate}
/>,
)
fireEvent.click(screen.getByTestId('modal-extra'))
await waitFor(() => {
expect(mockDeletePluginOAuthCustomClient).toHaveBeenCalled()
})
expect(mockOnClose).toHaveBeenCalled()
expect(mockOnUpdate).toHaveBeenCalled()
expect(mockInvalidPluginOAuthClientSchema).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
it('should render readme entrance when detail is provided', () => {
const payload = { ...basePayload, detail: { name: 'Test' } as never }
render(
@ -183,4 +349,26 @@ describe('OAuthClientSettings', () => {
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
})
it('should stay open when clicking inside the modal from a popover', async () => {
const user = userEvent.setup()
const mockOnClose = vi.fn()
const mockOnPopoverClose = vi.fn()
render(
<PopoverSettingsHarness
OAuthClientSettings={OAuthClientSettings}
onClose={mockOnClose}
onPopoverClose={mockOnPopoverClose}
/>,
)
const form = await screen.findByTestId('auth-form')
await user.click(form)
expect(mockOnClose).not.toHaveBeenCalled()
expect(mockOnPopoverClose).not.toHaveBeenCalled()
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})

View File

@ -3,11 +3,6 @@ import type { PluginPayload } from '../types'
import type { FormSchema } from '@/app/components/base/form/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
RiClipboardLine,
RiEqualizer2Line,
RiInformation2Fill,
} from '@remixicon/react'
import {
memo,
useCallback,
@ -40,10 +35,12 @@ export type AddOAuthButtonProps = {
schema?: FormSchema[]
is_oauth_custom_client_enabled?: boolean
is_system_oauth_params_exists?: boolean
client_params?: Record<string, any>
client_params?: Record<string, unknown>
redirect_uri?: string
}
}
type OAuthData = NonNullable<AddOAuthButtonProps['oAuthData']>
const AddOAuthButton = ({
pluginPayload,
buttonVariant = 'primary',
@ -59,22 +56,27 @@ const AddOAuthButton = ({
const { t } = useTranslation()
const renderI18nObject = useRenderI18nObject()
const [isOAuthSettingsOpen, setIsOAuthSettingsOpen] = useState(false)
const [isOAuthSettingsMounted, setIsOAuthSettingsMounted] = useState(false)
const { mutateAsync: getPluginOAuthUrl } = useGetPluginOAuthUrlHook(pluginPayload)
const { data, isLoading } = useGetPluginOAuthClientSchemaHook(pluginPayload)
const mergedOAuthData = useMemo(() => {
const mergedOAuthData = useMemo<OAuthData>(() => {
if (oAuthData)
return oAuthData
return data
return data || {}
}, [oAuthData, data])
const {
schema = [],
is_oauth_custom_client_enabled,
is_system_oauth_params_exists,
client_params,
client_params = {},
redirect_uri,
} = mergedOAuthData as any || {}
} = mergedOAuthData
const isConfigured = is_system_oauth_params_exists || is_oauth_custom_client_enabled
const openOAuthSettings = useCallback(() => {
setIsOAuthSettingsMounted(true)
setIsOAuthSettingsOpen(true)
}, [])
const handleOAuth = useCallback(async () => {
const { authorization_url } = await getPluginOAuthUrl()
@ -91,7 +93,7 @@ const AddOAuthButton = ({
<div className="w-full">
<div className="mb-4 flex rounded-xl bg-background-section-burn p-4">
<div className="mr-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg">
<RiInformation2Fill className="h-5 w-5 text-text-accent" />
<span className="i-ri-information-2-fill h-5 w-5 text-text-accent" />
</div>
<div className="w-0 grow">
<div className="mb-1.5 system-sm-regular">
@ -107,7 +109,7 @@ const AddOAuthButton = ({
navigator.clipboard.writeText(redirect_uri || '')
}}
>
<RiClipboardLine className="h-4 w-4" />
<span className="i-ri-clipboard-line h-4 w-4" />
</ActionButton>
</div>
)
@ -232,10 +234,10 @@ const AddOAuthButton = ({
)}
onClick={(e) => {
e.stopPropagation()
setIsOAuthSettingsOpen(true)
openOAuthSettings()
}}
>
<RiEqualizer2Line className="h-4 w-4" />
<span className="i-ri-equalizer-2-line h-4 w-4" />
</div>
</Button>
)
@ -244,18 +246,20 @@ const AddOAuthButton = ({
!isConfigured && (
<Button
variant={buttonVariant}
onClick={() => setIsOAuthSettingsOpen(true)}
onClick={openOAuthSettings}
disabled={disabled}
className="w-full"
>
<RiEqualizer2Line className="mr-0.5 h-4 w-4" />
<span className="mr-0.5 i-ri-equalizer-2-line h-4 w-4" />
{t('auth.setupOAuth', { ns: 'plugin' })}
</Button>
)
}
{
isOAuthSettingsOpen && (
isOAuthSettingsMounted && (
<OAuthClientSettings
open={isOAuthSettingsOpen}
onOpenChange={setIsOAuthSettingsOpen}
pluginPayload={pluginPayload}
onClose={() => setIsOAuthSettingsOpen(false)}
disabled={disabled || isLoading}

View File

@ -140,7 +140,10 @@ const ApiKeyModal = ({
open={open}
onOpenChange={handleOpenChange}
>
<DialogContent className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!">
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[640px]! max-w-[calc(100vw-2rem)]! p-0!"
>
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">

View File

@ -4,6 +4,7 @@ import type {
FormSchema,
} from '@/app/components/base/form/types'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import {
useForm,
@ -17,7 +18,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import Modal from '@/app/components/base/modal/modal'
import { ReadmeEntrance } from '../../readme-panel/entrance'
import { ReadmeShowType } from '../../readme-panel/store'
import {
@ -26,10 +26,12 @@ import {
useSetPluginOAuthCustomClientHook,
} from '../hooks/use-credential'
type OAuthClientSettingsProps = {
export type OAuthClientSettingsProps = {
pluginPayload: PluginPayload
open?: boolean
onOpenChange?: (open: boolean) => void
onClose?: () => void
editValues?: Record<string, any>
editValues?: Record<string, unknown>
disabled?: boolean
schemas: FormSchema[]
onAuth?: () => Promise<void>
@ -38,6 +40,8 @@ type OAuthClientSettingsProps = {
}
const OAuthClientSettings = ({
pluginPayload,
open = true,
onOpenChange,
onClose,
editValues,
disabled,
@ -53,11 +57,16 @@ const OAuthClientSettings = ({
doingActionRef.current = value
setDoingAction(value)
}, [])
const handleOpenChange = useCallback((nextOpen: boolean) => {
onOpenChange?.(nextOpen)
if (!nextOpen)
onClose?.()
}, [onClose, onOpenChange])
const defaultValues = schemas.reduce((acc, schema) => {
if (schema.default)
acc[schema.name] = schema.default
return acc
}, {} as Record<string, any>)
}, {} as Record<string, unknown>)
const { mutateAsync: setPluginOAuthCustomClient } = useSetPluginOAuthCustomClientHook(pluginPayload)
const invalidPluginOAuthClientSchema = useInvalidPluginOAuthClientSchemaHook(pluginPayload)
const formRef = useRef<FormRefObject>(null)
@ -87,6 +96,7 @@ const OAuthClientSettings = ({
})
toast.success(t('api.actionSuccess', { ns: 'common' }))
onOpenChange?.(false)
onClose?.()
onUpdate?.()
invalidPluginOAuthClientSchema()
@ -94,7 +104,7 @@ const OAuthClientSettings = ({
finally {
handleSetDoingAction(false)
}
}, [onClose, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
}, [onClose, onOpenChange, onUpdate, invalidPluginOAuthClientSchema, setPluginOAuthCustomClient, t, handleSetDoingAction])
const handleConfirmAndAuthorize = useCallback(async () => {
await handleConfirm()
@ -110,6 +120,7 @@ const OAuthClientSettings = ({
handleSetDoingAction(true)
await deletePluginOAuthCustomClient()
toast.success(t('api.actionSuccess', { ns: 'common' }))
onOpenChange?.(false)
onClose?.()
onUpdate?.()
invalidPluginOAuthClientSchema()
@ -117,53 +128,89 @@ const OAuthClientSettings = ({
finally {
handleSetDoingAction(false)
}
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose])
}, [onUpdate, invalidPluginOAuthClientSchema, deletePluginOAuthCustomClient, t, handleSetDoingAction, onClose, onOpenChange])
const form = useForm({
defaultValues: editValues || defaultValues,
})
const __oauth_client__ = useStore(form.store, s => s.values.__oauth_client__)
const isDisabled = disabled || doingAction
return (
<Modal
title={t('auth.oauthClientSettings', { ns: 'plugin' })}
confirmButtonText={t('auth.saveAndAuth', { ns: 'plugin' })}
cancelButtonText={t('auth.saveOnly', { ns: 'plugin' })}
extraButtonText={t('operation.cancel', { ns: 'common' })}
showExtraButton
extraButtonVariant="secondary"
onExtraButtonClick={onClose}
onClose={onClose}
onCancel={handleConfirm}
onConfirm={handleConfirmAndAuthorize}
disabled={disabled || doingAction}
footerSlot={
__oauth_client__ === 'custom' && hasOriginalClientParams && (
<div className="grow">
<Button
variant="secondary"
className="text-components-button-destructive-secondary-text"
disabled={disabled || doingAction || !editValues}
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
</div>
)
}
containerClassName="pt-0"
wrapperClassName="z-1002!"
clickOutsideNotClose={true}
<Dialog
open={open}
onOpenChange={handleOpenChange}
>
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
)}
<AuthForm
formFromProps={form}
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
</Modal>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[480px]! max-w-[calc(100vw-2rem)]! p-0!"
>
<div data-testid="modal" className="flex max-h-[80dvh] flex-col">
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle data-testid="modal-title" className="title-2xl-semi-bold text-text-primary">
{t('auth.oauthClientSettings', { ns: 'plugin' })}
</DialogTitle>
<DialogCloseButton
data-testid="modal-x-close"
className="top-5 right-5 h-8 w-8 rounded-lg"
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3 pt-0">
{pluginPayload.detail && (
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
)}
<AuthForm
formFromProps={form}
ref={formRef}
formSchemas={schemas}
defaultValues={editValues || defaultValues}
disabled={disabled}
/>
</div>
<div className="flex shrink-0 justify-between p-6 pt-5">
<div>
{__oauth_client__ === 'custom' && hasOriginalClientParams && (
<Button
data-testid="modal-extra"
variant="secondary"
className="text-components-button-destructive-secondary-text"
disabled={isDisabled || !editValues}
onClick={handleRemove}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)}
</div>
<div className="flex items-center">
<Button
data-testid="modal-close"
variant="secondary"
onClick={() => handleOpenChange(false)}
disabled={isDisabled}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<div className="mx-3 h-4 w-px bg-divider-regular"></div>
<Button
data-testid="modal-cancel"
onClick={handleConfirm}
disabled={isDisabled}
>
{t('auth.saveOnly', { ns: 'plugin' })}
</Button>
<Button
data-testid="modal-confirm"
className="ml-2"
variant="primary"
onClick={handleConfirmAndAuthorize}
disabled={isDisabled}
>
{t('auth.saveAndAuth', { ns: 'plugin' })}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,6 +1,6 @@
import type * as React from 'react'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import { TriggerCredentialTypeEnum } from '@/app/components/workflow/block-selector/types'
@ -134,36 +134,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}),
}))
vi.mock('@/app/components/base/modal/modal', () => ({
default: ({
children,
onClose,
onConfirm,
title,
confirmButtonText,
bottomSlot,
size,
disabled,
}: {
children: React.ReactNode
onClose: () => void
onConfirm: () => void
title: string
confirmButtonText: string
bottomSlot?: React.ReactNode
size?: string
disabled?: boolean
}) => (
<div data-testid="modal" data-size={size} data-disabled={disabled}>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-content">{children}</div>
<div data-testid="modal-bottom-slot">{bottomSlot}</div>
<button data-testid="modal-confirm" onClick={onConfirm} disabled={disabled}>{confirmButtonText}</button>
<button data-testid="modal-close" onClick={onClose}>Close</button>
</div>
),
}))
type MockFormValuesConfig = {
values: Record<string, unknown>
isCheckValidated: boolean

View File

@ -1,8 +1,15 @@
'use client'
import type { TriggerSubscriptionBuilder } from '@/app/components/workflow/block-selector/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { EncryptedBottom } from '@/app/components/base/encrypted-bottom'
import Modal from '@/app/components/base/modal/modal'
import { SupportedCreationMethods } from '@/app/components/plugins/types'
import {
ConfigurationStepContent,
@ -48,46 +55,93 @@ export const CommonCreateModal = ({ onClose, createType, builder }: Props) => {
const isApiKeyType = createType === SupportedCreationMethods.APIKEY
const isVerifyStep = currentStep === ApiKeyStep.Verify
const isConfigurationStep = currentStep === ApiKeyStep.Configuration
const isDisabled = isVerifyingCredentials || isBuilding
const modalSize = createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'
return (
<Modal
title={t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
confirmButtonText={confirmButtonText}
onClose={onClose}
onCancel={onClose}
onConfirm={handleConfirm}
disabled={isVerifyingCredentials || isBuilding}
bottomSlot={isVerifyStep ? <EncryptedBottom /> : null}
size={createType === SupportedCreationMethods.MANUAL ? 'md' : 'sm'}
containerClassName="min-h-[360px]"
clickOutsideNotClose
>
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
<Dialog open disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className={cn(
'flex max-h-[80%] min-h-[360px] flex-col overflow-hidden p-0 shadow-xs',
modalSize === 'md'
? 'w-[640px] max-w-[calc(100vw-2rem)]'
: 'w-[480px] max-w-[calc(100vw-2rem)]',
)}
>
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="modal"
data-size={modalSize}
data-disabled={isDisabled}
>
<div className="relative shrink-0 p-6 pr-14 pb-3">
<DialogTitle className="title-2xl-semi-bold text-text-primary" data-testid="modal-title">
{t(MODAL_TITLE_KEY_MAP[createType], { ns: 'pluginTrigger' })}
</DialogTitle>
<DialogCloseButton
className="top-5 right-5 h-8 w-8 rounded-lg [&>span]:h-5 [&>span]:w-5"
data-testid="modal-close"
onClick={onClose}
/>
</div>
{isVerifyStep && (
<VerifyStepContent
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
onChange={handleApiKeyCredentialsChange}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
{isApiKeyType && <MultiSteps currentStep={currentStep} />}
{isConfigurationStep && (
<ConfigurationStepContent
createType={createType}
subscriptionBuilder={subscriptionBuilder}
subscriptionFormRef={formRefs.subscriptionFormRef}
autoCommonParametersSchema={autoCommonParametersSchema}
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
manualPropertiesSchema={manualPropertiesSchema}
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
onManualPropertiesChange={handleManualPropertiesChange}
logs={logData?.logs || []}
pluginId={detail?.plugin_id || ''}
pluginName={detail?.name || ''}
provider={detail?.provider || ''}
/>
)}
</Modal>
{isVerifyStep && (
<VerifyStepContent
apiKeyCredentialsSchema={apiKeyCredentialsSchema}
apiKeyCredentialsFormRef={formRefs.apiKeyCredentialsFormRef}
onChange={handleApiKeyCredentialsChange}
/>
)}
{isConfigurationStep && (
<ConfigurationStepContent
createType={createType}
subscriptionBuilder={subscriptionBuilder}
subscriptionFormRef={formRefs.subscriptionFormRef}
autoCommonParametersSchema={autoCommonParametersSchema}
autoCommonParametersFormRef={formRefs.autoCommonParametersFormRef}
manualPropertiesSchema={manualPropertiesSchema}
manualPropertiesFormRef={formRefs.manualPropertiesFormRef}
onManualPropertiesChange={handleManualPropertiesChange}
logs={logData?.logs || []}
pluginId={detail?.plugin_id || ''}
pluginName={detail?.name || ''}
provider={detail?.provider || ''}
/>
)}
</div>
<div className="flex shrink-0 justify-end p-6 pt-5">
<div className="flex items-center">
<Button
disabled={isDisabled}
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
className="ml-2"
variant="primary"
disabled={isDisabled}
data-testid="modal-confirm"
onClick={handleConfirm}
>
{confirmButtonText}
</Button>
</div>
</div>
{isVerifyStep && (
<div className="shrink-0">
<EncryptedBottom />
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -330,6 +330,27 @@ describe('publisher', () => {
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
})
it('should keep confirm dialog mounted when first publish opens follow-up overlay', async () => {
mockPublishedAt.mockReturnValue(null)
renderWithQueryClient(<Publisher />)
fireEvent.click(screen.getByText('workflow.common.publish'))
await waitFor(() => {
expect(screen.getByText('workflow.common.publishUpdate')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /workflow.common.publishUpdate/i }))
await waitFor(() => {
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
fireEvent.mouseDown(document.body)
expect(screen.getByText('pipeline.common.confirmPublish')).toBeInTheDocument()
})
})
})

View File

@ -1,6 +1,8 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiArrowDownSLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import {
memo,
useCallback,
@ -13,13 +15,19 @@ import Popup from './popup'
const Publisher = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!newOpen && confirmVisible)
return
if (newOpen)
handleSyncWorkflowDraft(true)
setOpen(newOpen)
}, [handleSyncWorkflowDraft])
}, [confirmVisible, handleSyncWorkflowDraft])
const closePopover = useCallback(() => {
setOpen(false)
}, [])
return (
<Popover
@ -42,9 +50,14 @@ const Publisher = () => {
placement="bottom-end"
sideOffset={4}
alignOffset={40}
popupClassName="border-none bg-transparent shadow-none"
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
>
<Popup onRequestClose={() => handleOpenChange(false)} />
<Popup
onRequestClose={closePopover}
confirmVisible={confirmVisible}
onShowConfirm={showConfirm}
onHideConfirm={hideConfirm}
/>
</PopoverContent>
</Popover>
)

View File

@ -41,9 +41,17 @@ import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type PopupProps = {
onRequestClose?: () => void
confirmVisible?: boolean
onShowConfirm?: () => void
onHideConfirm?: () => void
}
const Popup = ({ onRequestClose }: PopupProps) => {
const Popup = ({
onRequestClose,
confirmVisible: controlledConfirmVisible,
onShowConfirm,
onHideConfirm,
}: PopupProps) => {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
@ -60,24 +68,32 @@ const Popup = ({ onRequestClose }: PopupProps) => {
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const apiReferenceUrl = useDatasetApiAccessUrl()
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
const [localConfirmVisible, { setFalse: hideLocalConfirm, setTrue: showLocalConfirm }] = useBoolean(false)
const confirmVisible = controlledConfirmVisible ?? localConfirmVisible
const showConfirm = onShowConfirm ?? showLocalConfirm
const hideConfirm = onHideConfirm ?? hideLocalConfirm
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
const invalidDatasetList = useInvalidDatasetList()
const handleHideConfirm = useCallback(() => {
hideConfirm()
onRequestClose?.()
}, [hideConfirm, onRequestClose])
const handlePublish = useCallback(async (params?: PublishWorkflowParams) => {
if (publishing)
return
let startedPublishing = false
try {
const checked = await handleCheckBeforePublish()
if (checked) {
if (!publishedAt && !confirmVisible) {
onRequestClose?.()
showConfirm()
return
}
startedPublishing = true
showPublishing()
const res = await publishWorkflow({
url: `/rag/pipelines/${pipelineId}/workflows/publish`,
@ -114,12 +130,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
toast.error(t('publishPipeline.error.message', { ns: 'datasetPipeline' }))
}
finally {
if (publishing)
if (startedPublishing)
hidePublishing()
if (confirmVisible)
hideConfirm()
handleHideConfirm()
}
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm, onRequestClose])
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, handleHideConfirm])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (published)
@ -163,10 +179,12 @@ const Popup = ({ onRequestClose }: PopupProps) => {
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
setShowPricingModal()
else
}
else {
setShowPublishAsKnowledgePipelineModal()
}
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
return (
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
@ -238,7 +256,7 @@ const Popup = ({ onRequestClose }: PopupProps) => {
</div>
</Button>
</div>
<AlertDialog open={confirmVisible} onOpenChange={open => !open && hideConfirm()}>
<AlertDialog open={confirmVisible} onOpenChange={open => !open && handleHideConfirm()}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle

View File

@ -3,10 +3,10 @@ import type { FC } from 'react'
import type { Field as FieldType } from '../../../../../llm/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiMoreFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { Type } from '../../../../../llm/types'
import { getFieldType } from '../../../../../llm/utils'
import TreeIndentLine from '../tree-indent-line'
@ -38,24 +38,32 @@ const Field: FC<Props> = ({
return null
return (
<div>
<Tooltip popupContent={t('structOutput.moreFillTip', { ns: 'app' })} disabled={depth !== MAX_DEPTH + 1}>
<div
className={cn('flex items-center justify-between rounded-md pr-2', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className="flex grow items-stretch">
<TreeIndentLine depth={depth} />
{depth === MAX_DEPTH + 1
? (
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
)
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
<Tooltip>
<TooltipTrigger
disabled={depth !== MAX_DEPTH + 1}
render={(
<div
className={cn('flex items-center justify-between rounded-md pr-2 outline-none focus:outline-none focus-visible:outline-none', !readonly && 'hover:bg-state-base-hover', depth !== MAX_DEPTH + 1 && 'cursor-pointer')}
onMouseDown={() => !readonly && onSelect?.([...valueSelector, name])}
>
<div className="flex grow items-stretch">
<TreeIndentLine depth={depth} />
{depth === MAX_DEPTH + 1
? (
<RiMoreFill className="h-3 w-3 text-text-tertiary" />
)
: (<div className={cn('h-6 w-0 grow truncate system-sm-medium leading-6 text-text-secondary', isHighlight && 'text-text-accent')}>{name}</div>)}
</div>
{depth < MAX_DEPTH + 1 && (
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
</div>
{depth < MAX_DEPTH + 1 && (
<div className="ml-2 shrink-0 system-xs-regular text-text-tertiary">{getFieldType(payload)}</div>
)}
</div>
)}
</div>
/>
<TooltipContent>
{t('structOutput.moreFillTip', { ns: 'app' })}
</TooltipContent>
</Tooltip>
{depth <= MAX_DEPTH && payload.type === Type.object && payload.properties && (

View File

@ -29,6 +29,7 @@ import {
} from './var-reference-vars.helpers'
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
const resolveValueSelector = ({
itemData,
@ -210,7 +211,7 @@ const Item: FC<ItemProps> = ({
className={cn(
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-none focus:outline-none focus-visible:outline-none',
className,
)}
data-selected={isSelected ? 'true' : 'false'}
@ -263,7 +264,7 @@ const Item: FC<ItemProps> = ({
<PopoverContent
placement="left-start"
sideOffset={0}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
popupClassName={cn(VAR_REFERENCE_CHILD_POPUP_CLASS_NAME, 'border-none bg-transparent p-0 shadow-none backdrop-blur-none')}
positionerProps={{
style: {
zIndex: zIndex || 100,