Compare commits

...

6 Commits

Author SHA1 Message Date
wangxiaolei
0711dd4159 feat: enhance start node object value check (#30732)
Some checks are pending
autofix.ci / autofix (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Waiting to run
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Waiting to run
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Waiting to run
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Blocked by required conditions
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Blocked by required conditions
Main CI Pipeline / Check Changed Files (push) Waiting to run
Main CI Pipeline / API Tests (push) Blocked by required conditions
Main CI Pipeline / Web Tests (push) Blocked by required conditions
Main CI Pipeline / Style Check (push) Waiting to run
Main CI Pipeline / VDB Tests (push) Blocked by required conditions
Main CI Pipeline / DB Migration Test (push) Blocked by required conditions
2026-01-09 16:13:17 +08:00
QuantumGhost
ae0a26f5b6 revert: "fix: fix assign value stand as default (#30651)" (#30717)
The original fix seems correct on its own. However, for chatflows with multiple answer nodes, the `message_replace` command only preserves the output of the last executed answer node.
2026-01-09 16:08:24 +08:00
Stephen Zhou
d4432ed80f refactor: marketplace state management (#30702)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:31:24 +08:00
lif
9d9f027246 fix(web): invalidate app list cache after deleting app from detail page (#30751)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-09 14:08:37 +08:00
wangxiaolei
77f097ce76 fix: fix enhance app mode check (#30758) 2026-01-09 14:07:40 +08:00
Maries
7843afc91c feat(workflows): add agent-dev deploy workflow (#30774) 2026-01-09 13:55:49 +08:00
51 changed files with 763 additions and 2839 deletions

View File

@@ -1,4 +1,4 @@
name: Deploy Trigger Dev
name: Deploy Agent Dev
permissions:
contents: read
@@ -7,7 +7,7 @@ on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/trigger-dev"
- "deploy/agent-dev"
types:
- completed
@@ -16,12 +16,12 @@ jobs:
runs-on: ubuntu-latest
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/trigger-dev'
github.event.workflow_run.head_branch == 'deploy/agent-dev'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v0.1.8
with:
host: ${{ secrets.TRIGGER_SSH_HOST }}
host: ${{ secrets.AGENT_DEV_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |

View File

@@ -1,4 +1,3 @@
import json
from collections.abc import Sequence
from enum import StrEnum, auto
from typing import Any, Literal
@@ -121,7 +120,7 @@ class VariableEntity(BaseModel):
allowed_file_types: Sequence[FileType] | None = Field(default_factory=list)
allowed_file_extensions: Sequence[str] | None = Field(default_factory=list)
allowed_file_upload_methods: Sequence[FileTransferMethod] | None = Field(default_factory=list)
json_schema: str | None = Field(default=None)
json_schema: dict | None = Field(default=None)
@field_validator("description", mode="before")
@classmethod
@@ -135,17 +134,11 @@ class VariableEntity(BaseModel):
@field_validator("json_schema")
@classmethod
def validate_json_schema(cls, schema: str | None) -> str | None:
def validate_json_schema(cls, schema: dict | None) -> dict | None:
if schema is None:
return None
try:
json_schema = json.loads(schema)
except json.JSONDecodeError:
raise ValueError(f"invalid json_schema value {schema}")
try:
Draft7Validator.check_schema(json_schema)
Draft7Validator.check_schema(schema)
except SchemaError as e:
raise ValueError(f"Invalid JSON schema: {e.message}")
return schema

View File

@@ -26,7 +26,6 @@ class AdvancedChatAppConfigManager(BaseAppConfigManager):
@classmethod
def get_app_config(cls, app_model: App, workflow: Workflow) -> AdvancedChatAppConfig:
features_dict = workflow.features_dict
app_mode = AppMode.value_of(app_model.mode)
app_config = AdvancedChatAppConfig(
tenant_id=app_model.tenant_id,

View File

@@ -358,25 +358,6 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if node_finish_resp:
yield node_finish_resp
# For ANSWER nodes, check if we need to send a message_replace event
# Only send if the final output differs from the accumulated task_state.answer
# This happens when variables were updated by variable_assigner during workflow execution
if event.node_type == NodeType.ANSWER and event.outputs:
final_answer = event.outputs.get("answer")
if final_answer is not None and final_answer != self._task_state.answer:
logger.info(
"ANSWER node final output '%s' differs from accumulated answer '%s', sending message_replace event",
final_answer,
self._task_state.answer,
)
# Update the task state answer
self._task_state.answer = str(final_answer)
# Send message_replace event to update the UI
yield self._message_cycle_manager.message_replace_to_stream_response(
answer=str(final_answer),
reason="variable_update",
)
def _handle_node_failed_events(
self,
event: Union[QueueNodeFailedEvent, QueueNodeExceptionEvent],

View File

@@ -1,4 +1,3 @@
import json
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Union, final
@@ -76,12 +75,24 @@ class BaseAppGenerator:
user_inputs = {**user_inputs, **files_inputs, **file_list_inputs}
# Check if all files are converted to File
if any(filter(lambda v: isinstance(v, dict), user_inputs.values())):
raise ValueError("Invalid input type")
if any(
filter(lambda v: isinstance(v, dict), filter(lambda item: isinstance(item, list), user_inputs.values()))
):
raise ValueError("Invalid input type")
invalid_dict_keys = [
k
for k, v in user_inputs.items()
if isinstance(v, dict)
and entity_dictionary[k].type not in {VariableEntityType.FILE, VariableEntityType.JSON_OBJECT}
]
if invalid_dict_keys:
raise ValueError(f"Invalid input type for {invalid_dict_keys}")
invalid_list_dict_keys = [
k
for k, v in user_inputs.items()
if isinstance(v, list)
and any(isinstance(item, dict) for item in v)
and entity_dictionary[k].type != VariableEntityType.FILE_LIST
]
if invalid_list_dict_keys:
raise ValueError(f"Invalid input type for {invalid_list_dict_keys}")
return user_inputs
@@ -178,12 +189,8 @@ class BaseAppGenerator:
elif value == 0:
value = False
case VariableEntityType.JSON_OBJECT:
if not isinstance(value, str):
raise ValueError(f"{variable_entity.variable} in input form must be a string")
try:
json.loads(value)
except json.JSONDecodeError:
raise ValueError(f"{variable_entity.variable} in input form must be a valid JSON object")
if not isinstance(value, dict):
raise ValueError(f"{variable_entity.variable} in input form must be a dict")
case _:
raise AssertionError("this statement should be unreachable.")

View File

@@ -1,4 +1,3 @@
import json
from typing import Any
from jsonschema import Draft7Validator, ValidationError
@@ -43,25 +42,22 @@ class StartNode(Node[StartNodeData]):
if value is None and variable.required:
raise ValueError(f"{key} is required in input form")
# If no value provided, skip further processing for this key
if not value:
continue
if not isinstance(value, dict):
raise ValueError(f"JSON object for '{key}' must be an object")
# Overwrite with normalized dict to ensure downstream consistency
node_inputs[key] = value
# If schema exists, then validate against it
schema = variable.json_schema
if not schema:
continue
if not value:
continue
try:
json_schema = json.loads(schema)
except json.JSONDecodeError as e:
raise ValueError(f"{schema} must be a valid JSON object")
try:
json_value = json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"{value} must be a valid JSON object")
try:
Draft7Validator(json_schema).validate(json_value)
Draft7Validator(schema).validate(value)
except ValidationError as e:
raise ValueError(f"JSON object for '{key}' does not match schema: {e.message}")
node_inputs[key] = json_value

View File

@@ -1,390 +0,0 @@
"""
Tests for AdvancedChatAppGenerateTaskPipeline._handle_node_succeeded_event method,
specifically testing the ANSWER node message_replace logic.
"""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity
from core.app.entities.queue_entities import QueueNodeSucceededEvent
from core.workflow.enums import NodeType
from models import EndUser
from models.model import AppMode
class TestAnswerNodeMessageReplace:
"""Test cases for ANSWER node message_replace event logic."""
@pytest.fixture
def mock_application_generate_entity(self):
"""Create a mock application generate entity."""
entity = Mock(spec=AdvancedChatAppGenerateEntity)
entity.task_id = "test-task-id"
entity.app_id = "test-app-id"
entity.workflow_run_id = "test-workflow-run-id"
# minimal app_config used by pipeline internals
entity.app_config = SimpleNamespace(
tenant_id="test-tenant-id",
app_id="test-app-id",
app_mode=AppMode.ADVANCED_CHAT,
app_model_config_dict={},
additional_features=None,
sensitive_word_avoidance=None,
)
entity.query = "test query"
entity.files = []
entity.extras = {}
entity.trace_manager = None
entity.inputs = {}
entity.invoke_from = "debugger"
return entity
@pytest.fixture
def mock_workflow(self):
"""Create a mock workflow."""
workflow = Mock()
workflow.id = "test-workflow-id"
workflow.features_dict = {}
return workflow
@pytest.fixture
def mock_queue_manager(self):
"""Create a mock queue manager."""
manager = Mock()
manager.listen.return_value = []
manager.graph_runtime_state = None
return manager
@pytest.fixture
def mock_conversation(self):
"""Create a mock conversation."""
conversation = Mock()
conversation.id = "test-conversation-id"
conversation.mode = "advanced_chat"
return conversation
@pytest.fixture
def mock_message(self):
"""Create a mock message."""
message = Mock()
message.id = "test-message-id"
message.query = "test query"
message.created_at = Mock()
message.created_at.timestamp.return_value = 1234567890
return message
@pytest.fixture
def mock_user(self):
"""Create a mock end user."""
user = MagicMock(spec=EndUser)
user.id = "test-user-id"
user.session_id = "test-session-id"
return user
@pytest.fixture
def mock_draft_var_saver_factory(self):
"""Create a mock draft variable saver factory."""
return Mock()
@pytest.fixture
def pipeline(
self,
mock_application_generate_entity,
mock_workflow,
mock_queue_manager,
mock_conversation,
mock_message,
mock_user,
mock_draft_var_saver_factory,
):
"""Create an AdvancedChatAppGenerateTaskPipeline instance with mocked dependencies."""
from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline
with patch("core.app.apps.advanced_chat.generate_task_pipeline.db"):
pipeline = AdvancedChatAppGenerateTaskPipeline(
application_generate_entity=mock_application_generate_entity,
workflow=mock_workflow,
queue_manager=mock_queue_manager,
conversation=mock_conversation,
message=mock_message,
user=mock_user,
stream=True,
dialogue_count=1,
draft_var_saver_factory=mock_draft_var_saver_factory,
)
# Initialize workflow run id to avoid validation errors
pipeline._workflow_run_id = "test-workflow-run-id"
# Mock the message cycle manager methods we need to track
pipeline._message_cycle_manager.message_replace_to_stream_response = Mock()
return pipeline
def test_answer_node_with_different_output_sends_message_replace(self, pipeline, mock_application_generate_entity):
"""
Test that when an ANSWER node's final output differs from accumulated answer,
a message_replace event is sent.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "initial answer"
# Create ANSWER node succeeded event with different final output
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={"answer": "updated final answer"},
)
# Mock the workflow response converter to avoid extra processing
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
responses = list(pipeline._handle_node_succeeded_event(event))
# Assert
assert pipeline._task_state.answer == "updated final answer"
# Verify message_replace was called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with(
answer="updated final answer", reason="variable_update"
)
def test_answer_node_with_same_output_does_not_send_message_replace(self, pipeline):
"""
Test that when an ANSWER node's final output is the same as accumulated answer,
no message_replace event is sent.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "same answer"
# Create ANSWER node succeeded event with same output
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={"answer": "same answer"},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "same answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_answer_node_with_none_output_does_not_send_message_replace(self, pipeline):
"""
Test that when an ANSWER node's output is None or missing 'answer' key,
no message_replace event is sent.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "existing answer"
# Create ANSWER node succeeded event with None output
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={"answer": None},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "existing answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_answer_node_with_empty_outputs_does_not_send_message_replace(self, pipeline):
"""
Test that when an ANSWER node has empty outputs dict,
no message_replace event is sent.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "existing answer"
# Create ANSWER node succeeded event with empty outputs
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "existing answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_answer_node_with_no_answer_key_in_outputs(self, pipeline):
"""
Test that when an ANSWER node's outputs don't contain 'answer' key,
no message_replace event is sent.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "existing answer"
# Create ANSWER node succeeded event without 'answer' key in outputs
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={"other_key": "some value"},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "existing answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_non_answer_node_does_not_send_message_replace(self, pipeline):
"""
Test that non-ANSWER nodes (e.g., LLM, END) don't trigger message_replace events.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "existing answer"
# Test with LLM node
llm_event = QueueNodeSucceededEvent(
node_execution_id="test-llm-execution-id",
node_id="test-llm-node",
node_type=NodeType.LLM,
start_at=datetime.now(),
outputs={"answer": "different answer"},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(llm_event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "existing answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_end_node_does_not_send_message_replace(self, pipeline):
"""
Test that END nodes don't trigger message_replace events even with 'answer' output.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "existing answer"
# Create END node succeeded event with answer output
event = QueueNodeSucceededEvent(
node_execution_id="test-end-execution-id",
node_id="test-end-node",
node_type=NodeType.END,
start_at=datetime.now(),
outputs={"answer": "different answer"},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should remain unchanged
assert pipeline._task_state.answer == "existing answer"
# Verify message_replace was NOT called
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_not_called()
def test_answer_node_with_numeric_output_converts_to_string(self, pipeline):
"""
Test that when an ANSWER node's final output is numeric,
it gets converted to string properly.
"""
# Arrange: Set initial accumulated answer
pipeline._task_state.answer = "text answer"
# Create ANSWER node succeeded event with numeric output
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={"answer": 12345},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: answer should be converted to string
assert pipeline._task_state.answer == "12345"
# Verify message_replace was called with string
pipeline._message_cycle_manager.message_replace_to_stream_response.assert_called_once_with(
answer="12345", reason="variable_update"
)
def test_answer_node_files_are_recorded(self, pipeline):
"""
Test that ANSWER nodes properly record files from outputs.
"""
# Arrange
pipeline._task_state.answer = "existing answer"
# Create ANSWER node succeeded event with files
event = QueueNodeSucceededEvent(
node_execution_id="test-node-execution-id",
node_id="test-answer-node",
node_type=NodeType.ANSWER,
start_at=datetime.now(),
outputs={
"answer": "same answer",
"files": [
{"type": "image", "transfer_method": "remote_url", "remote_url": "http://example.com/img.png"}
],
},
)
# Mock the workflow response converter
pipeline._workflow_response_converter.fetch_files_from_node_outputs = Mock(return_value=event.outputs["files"])
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = Mock(return_value=None)
pipeline._save_output_for_event = Mock()
# Act
list(pipeline._handle_node_succeeded_event(event))
# Assert: files should be recorded
assert len(pipeline._recorded_files) == 1
assert pipeline._recorded_files[0] == event.outputs["files"][0]

View File

@@ -58,6 +58,8 @@ def test_json_object_valid_schema():
}
)
schema = json.loads(schema)
variables = [
VariableEntity(
variable="profile",
@@ -68,7 +70,7 @@ def test_json_object_valid_schema():
)
]
user_inputs = {"profile": json.dumps({"age": 20, "name": "Tom"})}
user_inputs = {"profile": {"age": 20, "name": "Tom"}}
node = make_start_node(user_inputs, variables)
result = node._run()
@@ -87,6 +89,8 @@ def test_json_object_invalid_json_string():
"required": ["age", "name"],
}
)
schema = json.loads(schema)
variables = [
VariableEntity(
variable="profile",
@@ -97,12 +101,12 @@ def test_json_object_invalid_json_string():
)
]
# Missing closing brace makes this invalid JSON
# Providing a string instead of an object should raise a type error
user_inputs = {"profile": '{"age": 20, "name": "Tom"'}
node = make_start_node(user_inputs, variables)
with pytest.raises(ValueError, match='{"age": 20, "name": "Tom" must be a valid JSON object'):
with pytest.raises(ValueError, match="JSON object for 'profile' must be an object"):
node._run()
@@ -118,6 +122,8 @@ def test_json_object_does_not_match_schema():
}
)
schema = json.loads(schema)
variables = [
VariableEntity(
variable="profile",
@@ -129,7 +135,7 @@ def test_json_object_does_not_match_schema():
]
# age is a string, which violates the schema (expects number)
user_inputs = {"profile": json.dumps({"age": "twenty", "name": "Tom"})}
user_inputs = {"profile": {"age": "twenty", "name": "Tom"}}
node = make_start_node(user_inputs, variables)
@@ -149,6 +155,8 @@ def test_json_object_missing_required_schema_field():
}
)
schema = json.loads(schema)
variables = [
VariableEntity(
variable="profile",
@@ -160,7 +168,7 @@ def test_json_object_missing_required_schema_field():
]
# Missing required field "name"
user_inputs = {"profile": json.dumps({"age": 20})}
user_inputs = {"profile": {"age": 20}}
node = make_start_node(user_inputs, variables)

View File

@@ -2,11 +2,11 @@ import Marketplace from '@/app/components/plugins/marketplace'
import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
const PluginList = async () => {
const PluginList = () => {
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" showSearchParams={false} />}
marketplace={<Marketplace pluginTypeSwitchClassName="top-[60px]" />}
/>
)
}

View File

@@ -26,6 +26,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import { useInvalidateAppList } from '@/service/use-apps'
import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
@@ -66,6 +67,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const invalidateAppList = useInvalidateAppList()
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
@@ -191,6 +193,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
try {
await deleteApp(appDetail.id)
notify({ type: 'success', message: t('appDeleted', { ns: 'app' }) })
invalidateAppList()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
@@ -202,7 +205,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
})
}
setShowConfirmDelete(false)
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
}, [appDetail, invalidateAppList, notify, onPlanInfoChanged, replace, setAppDetail, t])
const { isCurrentWorkspaceEditor } = useAppContext()

View File

@@ -83,7 +83,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
if (!isJsonObject || !tempPayload.json_schema)
return ''
try {
return JSON.stringify(JSON.parse(tempPayload.json_schema), null, 2)
return tempPayload.json_schema
}
catch {
return ''

View File

@@ -10,6 +10,7 @@ const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
vi.mock('next/navigation', () => ({
useRouter: () => mockRouter,
useSearchParams: () => new URLSearchParams(''),
}))
// Mock app context

View File

@@ -12,6 +12,7 @@ import { useDebounceFn } from 'ahooks'
import dynamic from 'next/dynamic'
import {
useRouter,
useSearchParams,
} from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -36,6 +37,16 @@ import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import NewAppCard from './new-app-card'
// Define valid tabs at module scope to avoid re-creation on each render and stale closures
const validTabs = new Set<string | AppModeEnum>([
'all',
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
])
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
})
@@ -47,12 +58,41 @@ const List = () => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
const searchParams = useSearchParams()
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [activeTab, setActiveTab] = useQueryState(
'category',
parseAsString.withDefault('all').withOptions({ history: 'push' }),
)
// valid tabs for apps list; anything else should fallback to 'all'
// 1) Normalize legacy/incorrect query params like ?mode=discover -> ?category=all
useEffect(() => {
// avoid running on server
if (typeof window === 'undefined')
return
const mode = searchParams.get('mode')
if (!mode)
return
const url = new URL(window.location.href)
url.searchParams.delete('mode')
if (validTabs.has(mode)) {
// migrate to category key
url.searchParams.set('category', mode)
}
else {
url.searchParams.set('category', 'all')
}
router.replace(url.pathname + url.search)
}, [router, searchParams])
// 2) If category has an invalid value (e.g., 'discover'), reset to 'all'
useEffect(() => {
if (!validTabs.has(activeTab))
setActiveTab('all')
}, [activeTab, setActiveTab])
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)

View File

@@ -37,7 +37,7 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
return
}
if (!inputValue)
if (inputValue == null)
return
if (item.type === InputVarType.singleFile) {
@@ -52,6 +52,20 @@ export const getProcessedInputs = (inputs: Record<string, any>, inputsForm: Inpu
else
processedInputs[item.variable] = getProcessedFiles(inputValue)
}
else if (item.type === InputVarType.jsonObject) {
// Prefer sending an object if the user entered valid JSON; otherwise keep the raw string.
try {
const v = typeof inputValue === 'string' ? JSON.parse(inputValue) : inputValue
if (v && typeof v === 'object' && !Array.isArray(v))
processedInputs[item.variable] = v
else
processedInputs[item.variable] = inputValue
}
catch {
// keep original string; backend will parse/validate
processedInputs[item.variable] = inputValue
}
}
})
return processedInputs

View File

@@ -0,0 +1,81 @@
import type { ActivePluginType } from './constants'
import type { PluginsSort, SearchParamsFromCollection } from './types'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { DEFAULT_SORT, PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceSearchParamsParsers } from './search-params'
const marketplaceSortAtom = atom<PluginsSort>(DEFAULT_SORT)
export function useMarketplaceSort() {
return useAtom(marketplaceSortAtom)
}
export function useMarketplaceSortValue() {
return useAtomValue(marketplaceSortAtom)
}
export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom)
}
/**
* Preserve the state for marketplace
*/
export const preserveSearchStateInQueryAtom = atom<boolean>(false)
const searchPluginTextAtom = atom<string>('')
const activePluginTypeAtom = atom<ActivePluginType>('all')
const filterPluginTagsAtom = atom<string[]>([])
export function useSearchPluginText() {
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
const queryState = useQueryState('q', marketplaceSearchParamsParsers.q)
const atomState = useAtom(searchPluginTextAtom)
return preserveSearchStateInQuery ? queryState : atomState
}
export function useActivePluginType() {
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
const queryState = useQueryState('category', marketplaceSearchParamsParsers.category)
const atomState = useAtom(activePluginTypeAtom)
return preserveSearchStateInQuery ? queryState : atomState
}
export function useFilterPluginTags() {
const preserveSearchStateInQuery = useAtomValue(preserveSearchStateInQueryAtom)
const queryState = useQueryState('tags', marketplaceSearchParamsParsers.tags)
const atomState = useAtom(filterPluginTagsAtom)
return preserveSearchStateInQuery ? queryState : atomState
}
/**
* Not all categories have collections, so we need to
* force the search mode for those categories.
*/
export const searchModeAtom = atom<true | null>(null)
export function useMarketplaceSearchMode() {
const [searchPluginText] = useSearchPluginText()
const [filterPluginTags] = useFilterPluginTags()
const [activePluginType] = useActivePluginType()
const searchMode = useAtomValue(searchModeAtom)
const isSearchMode = !!searchPluginText
|| filterPluginTags.length > 0
|| (searchMode ?? (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(activePluginType)))
return isSearchMode
}
export function useMarketplaceMoreClick() {
const [,setQ] = useSearchPluginText()
const setSort = useSetAtom(marketplaceSortAtom)
const setSearchMode = useSetAtom(searchModeAtom)
return useCallback((searchParams?: SearchParamsFromCollection) => {
if (!searchParams)
return
setQ(searchParams?.query || '')
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
setSearchMode(true)
}, [setQ, setSort, setSearchMode])
}

View File

@@ -1,6 +1,30 @@
import { PluginCategoryEnum } from '../types'
export const DEFAULT_SORT = {
sortBy: 'install_count',
sortOrder: 'DESC',
}
export const SCROLL_BOTTOM_THRESHOLD = 100
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
extension: PluginCategoryEnum.extension,
datasource: PluginCategoryEnum.datasource,
trigger: PluginCategoryEnum.trigger,
bundle: 'bundle',
} as const
type ValueOf<T> = T[keyof T]
export type ActivePluginType = ValueOf<typeof PLUGIN_TYPE_SEARCH_MAP>
export const PLUGIN_CATEGORY_WITH_COLLECTIONS = new Set<ActivePluginType>(
[
PLUGIN_TYPE_SEARCH_MAP.all,
PLUGIN_TYPE_SEARCH_MAP.tool,
],
)

View File

@@ -1,332 +0,0 @@
'use client'
import type {
ReactNode,
} from 'react'
import type { TagKey } from '../constants'
import type { Plugin } from '../types'
import type {
MarketplaceCollection,
PluginsSort,
SearchParams,
SearchParamsFromCollection,
} from './types'
import { debounce } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {
createContext,
useContextSelector,
} from 'use-context-selector'
import { useMarketplaceFilters } from '@/hooks/use-query-params'
import { useInstalledPluginList } from '@/service/use-plugins'
import {
getValidCategoryKeys,
getValidTagKeys,
} from '../utils'
import { DEFAULT_SORT } from './constants'
import {
useMarketplaceCollectionsAndPlugins,
useMarketplaceContainerScroll,
useMarketplacePlugins,
} from './hooks'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import {
getMarketplaceListCondition,
getMarketplaceListFilterType,
} from './utils'
export type MarketplaceContextValue = {
searchPluginText: string
handleSearchPluginTextChange: (text: string) => void
filterPluginTags: string[]
handleFilterPluginTagsChange: (tags: string[]) => void
activePluginType: string
handleActivePluginTypeChange: (type: string) => void
page: number
handlePageChange: () => void
plugins?: Plugin[]
pluginsTotal?: number
resetPlugins: () => void
sort: PluginsSort
handleSortChange: (sort: PluginsSort) => void
handleQueryPlugins: () => void
handleMoreClick: (searchParams: SearchParamsFromCollection) => void
marketplaceCollectionsFromClient?: MarketplaceCollection[]
setMarketplaceCollectionsFromClient: (collections: MarketplaceCollection[]) => void
marketplaceCollectionPluginsMapFromClient?: Record<string, Plugin[]>
setMarketplaceCollectionPluginsMapFromClient: (map: Record<string, Plugin[]>) => void
isLoading: boolean
isSuccessCollections: boolean
}
export const MarketplaceContext = createContext<MarketplaceContextValue>({
searchPluginText: '',
handleSearchPluginTextChange: noop,
filterPluginTags: [],
handleFilterPluginTagsChange: noop,
activePluginType: 'all',
handleActivePluginTypeChange: noop,
page: 1,
handlePageChange: noop,
plugins: undefined,
pluginsTotal: 0,
resetPlugins: noop,
sort: DEFAULT_SORT,
handleSortChange: noop,
handleQueryPlugins: noop,
handleMoreClick: noop,
marketplaceCollectionsFromClient: [],
setMarketplaceCollectionsFromClient: noop,
marketplaceCollectionPluginsMapFromClient: {},
setMarketplaceCollectionPluginsMapFromClient: noop,
isLoading: false,
isSuccessCollections: false,
})
type MarketplaceContextProviderProps = {
children: ReactNode
searchParams?: SearchParams
shouldExclude?: boolean
scrollContainerId?: string
showSearchParams?: boolean
}
export function useMarketplaceContext(selector: (value: MarketplaceContextValue) => any) {
return useContextSelector(MarketplaceContext, selector)
}
export const MarketplaceContextProvider = ({
children,
searchParams,
shouldExclude,
scrollContainerId,
showSearchParams,
}: MarketplaceContextProviderProps) => {
// Use nuqs hook for URL-based filter state
const [urlFilters, setUrlFilters] = useMarketplaceFilters()
const { data, isSuccess } = useInstalledPluginList(!shouldExclude)
const exclude = useMemo(() => {
if (shouldExclude)
return data?.plugins.map(plugin => plugin.plugin_id)
}, [data?.plugins, shouldExclude])
// Initialize from URL params (legacy support) or use nuqs state
const queryFromSearchParams = searchParams?.q || urlFilters.q
const tagsFromSearchParams = getValidTagKeys(urlFilters.tags as TagKey[])
const hasValidTags = !!tagsFromSearchParams.length
const hasValidCategory = getValidCategoryKeys(urlFilters.category)
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
const searchPluginTextRef = useRef(searchPluginText)
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
const filterPluginTagsRef = useRef(filterPluginTags)
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
const activePluginTypeRef = useRef(activePluginType)
const [sort, setSort] = useState(DEFAULT_SORT)
const sortRef = useRef(sort)
const {
marketplaceCollections: marketplaceCollectionsFromClient,
setMarketplaceCollections: setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMap: setMarketplaceCollectionPluginsMapFromClient,
queryMarketplaceCollectionsAndPlugins,
isLoading,
isSuccess: isSuccessCollections,
} = useMarketplaceCollectionsAndPlugins()
const {
plugins,
total: pluginsTotal,
resetPlugins,
queryPlugins,
queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced,
isLoading: isPluginsLoading,
fetchNextPage: fetchNextPluginsPage,
hasNextPage: hasNextPluginsPage,
page: pluginsPage,
} = useMarketplacePlugins()
const page = Math.max(pluginsPage || 0, 1)
useEffect(() => {
if (queryFromSearchParams || hasValidTags || hasValidCategory) {
queryPlugins({
query: queryFromSearchParams,
category: hasValidCategory,
tags: hasValidTags ? tagsFromSearchParams : [],
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
else {
if (shouldExclude && isSuccess) {
queryMarketplaceCollectionsAndPlugins({
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
}
}, [queryPlugins, queryMarketplaceCollectionsAndPlugins, isSuccess, exclude])
const handleQueryMarketplaceCollectionsAndPlugins = useCallback(() => {
queryMarketplaceCollectionsAndPlugins({
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
condition: getMarketplaceListCondition(activePluginTypeRef.current),
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
resetPlugins()
}, [exclude, queryMarketplaceCollectionsAndPlugins, resetPlugins])
const applyUrlFilters = useCallback(() => {
if (!showSearchParams)
return
const nextFilters = {
q: searchPluginTextRef.current,
category: activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
}
const categoryChanged = urlFilters.category !== nextFilters.category
setUrlFilters(nextFilters, {
history: categoryChanged ? 'push' : 'replace',
})
}, [setUrlFilters, showSearchParams, urlFilters.category])
const debouncedUpdateSearchParams = useMemo(() => debounce(() => {
applyUrlFilters()
}, 500), [applyUrlFilters])
const handleUpdateSearchParams = useCallback((debounced?: boolean) => {
if (debounced) {
debouncedUpdateSearchParams()
}
else {
applyUrlFilters()
}
}, [applyUrlFilters, debouncedUpdateSearchParams])
const handleQueryPlugins = useCallback((debounced?: boolean) => {
handleUpdateSearchParams(debounced)
if (debounced) {
queryPluginsWithDebounced({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
else {
queryPlugins({
query: searchPluginTextRef.current,
category: activePluginTypeRef.current === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginTypeRef.current,
tags: filterPluginTagsRef.current,
sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder,
exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current),
})
}
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
const handleQuery = useCallback((debounced?: boolean) => {
if (!searchPluginTextRef.current && !filterPluginTagsRef.current.length) {
handleUpdateSearchParams(debounced)
cancelQueryPluginsWithDebounced()
handleQueryMarketplaceCollectionsAndPlugins()
return
}
handleQueryPlugins(debounced)
}, [handleQueryMarketplaceCollectionsAndPlugins, handleQueryPlugins, cancelQueryPluginsWithDebounced, handleUpdateSearchParams])
const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text)
searchPluginTextRef.current = text
handleQuery(true)
}, [handleQuery])
const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags)
filterPluginTagsRef.current = tags
handleQuery()
}, [handleQuery])
const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type)
activePluginTypeRef.current = type
handleQuery()
}, [handleQuery])
const handleSortChange = useCallback((sort: PluginsSort) => {
setSort(sort)
sortRef.current = sort
handleQueryPlugins()
}, [handleQueryPlugins])
const handlePageChange = useCallback(() => {
if (hasNextPluginsPage)
fetchNextPluginsPage()
}, [fetchNextPluginsPage, hasNextPluginsPage])
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
setSearchPluginText(searchParams?.query || '')
searchPluginTextRef.current = searchParams?.query || ''
setSort({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
})
sortRef.current = {
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
}
handleQueryPlugins()
}, [handleQueryPlugins])
useMarketplaceContainerScroll(handlePageChange, scrollContainerId)
return (
<MarketplaceContext.Provider
value={{
searchPluginText,
handleSearchPluginTextChange,
filterPluginTags,
handleFilterPluginTagsChange,
activePluginType,
handleActivePluginTypeChange,
page,
handlePageChange,
plugins,
pluginsTotal,
resetPlugins,
sort,
handleSortChange,
handleQueryPlugins,
handleMoreClick,
marketplaceCollectionsFromClient,
setMarketplaceCollectionsFromClient,
marketplaceCollectionPluginsMapFromClient,
setMarketplaceCollectionPluginsMapFromClient,
isLoading: isLoading || isPluginsLoading,
isSuccessCollections,
}}
>
{children}
</MarketplaceContext.Provider>
)
}

View File

@@ -26,6 +26,9 @@ import {
getMarketplacePluginsByCollectionId,
} from './utils'
/**
* @deprecated Use useMarketplaceCollectionsAndPlugins from query.ts instead
*/
export const useMarketplaceCollectionsAndPlugins = () => {
const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
@@ -89,7 +92,9 @@ export const useMarketplacePluginsByCollectionId = (
isSuccess,
}
}
/**
* @deprecated Use useMarketplacePlugins from query.ts instead
*/
export const useMarketplacePlugins = () => {
const queryClient = useQueryClient()
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()

View File

@@ -0,0 +1,15 @@
'use client'
import { useHydrateAtoms } from 'jotai/utils'
import { preserveSearchStateInQueryAtom } from './atoms'
export function HydrateMarketplaceAtoms({
preserveSearchStateInQuery,
children,
}: {
preserveSearchStateInQuery: boolean
children: React.ReactNode
}) {
useHydrateAtoms([[preserveSearchStateInQueryAtom, preserveSearchStateInQuery]])
return <>{children}</>
}

View File

@@ -0,0 +1,45 @@
import type { SearchParams } from 'nuqs'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { createLoader } from 'nuqs/server'
import { getQueryClientServer } from '@/context/query-client-server'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS } from './constants'
import { marketplaceKeys } from './query'
import { marketplaceSearchParamsParsers } from './search-params'
import { getCollectionsParams, getMarketplaceCollectionsAndPlugins } from './utils'
// The server side logic should move to marketplace's codebase so that we can get rid of Next.js
async function getDehydratedState(searchParams?: Promise<SearchParams>) {
if (!searchParams) {
return
}
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
const params = await loadSearchParams(searchParams)
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
return
}
const queryClient = getQueryClientServer()
await queryClient.prefetchQuery({
queryKey: marketplaceKeys.collections(getCollectionsParams(params.category)),
queryFn: () => getMarketplaceCollectionsAndPlugins(getCollectionsParams(params.category)),
})
return dehydrate(queryClient)
}
export async function HydrateQueryClient({
searchParams,
children,
}: {
searchParams: Promise<SearchParams> | undefined
children: React.ReactNode
}) {
const dehydratedState = await getDehydratedState(searchParams)
return (
<HydrationBoundary state={dehydratedState}>
{children}
</HydrationBoundary>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,39 @@
import type { MarketplaceCollection, SearchParams } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import type { SearchParams } from 'nuqs'
import { TanstackQueryInitializer } from '@/context/query-client'
import { MarketplaceContextProvider } from './context'
import Description from './description'
import { HydrateMarketplaceAtoms } from './hydration-client'
import { HydrateQueryClient } from './hydration-server'
import ListWrapper from './list/list-wrapper'
import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
import { getMarketplaceCollectionsAndPlugins } from './utils'
type MarketplaceProps = {
showInstallButton?: boolean
shouldExclude?: boolean
searchParams?: SearchParams
pluginTypeSwitchClassName?: string
scrollContainerId?: string
showSearchParams?: boolean
/**
* Pass the search params from the request to prefetch data on the server
* and preserve the search params in the URL.
*/
searchParams?: Promise<SearchParams>
}
const Marketplace = async ({
showInstallButton = true,
shouldExclude,
searchParams,
pluginTypeSwitchClassName,
scrollContainerId,
showSearchParams = true,
searchParams,
}: MarketplaceProps) => {
let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
if (!shouldExclude) {
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
marketplaceCollectionPluginsMap = marketplaceCollectionsAndPluginsData.marketplaceCollectionPluginsMap
}
return (
<TanstackQueryInitializer>
<MarketplaceContextProvider
searchParams={searchParams}
shouldExclude={shouldExclude}
scrollContainerId={scrollContainerId}
showSearchParams={showSearchParams}
>
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
showSearchParams={showSearchParams}
/>
<ListWrapper
marketplaceCollections={marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap}
showInstallButton={showInstallButton}
/>
</MarketplaceContextProvider>
<HydrateQueryClient searchParams={searchParams}>
<HydrateMarketplaceAtoms preserveSearchStateInQuery={!!searchParams}>
<Description />
<StickySearchAndSwitchWrapper
pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<ListWrapper
showInstallButton={showInstallButton}
/>
</HydrateMarketplaceAtoms>
</HydrateQueryClient>
</TanstackQueryInitializer>
)
}

View File

@@ -1,6 +1,6 @@
import type { MarketplaceCollection, SearchParamsFromCollection } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import List from './index'
@@ -30,23 +30,27 @@ vi.mock('#i18n', () => ({
useLocale: () => 'en-US',
}))
// Mock useMarketplaceContext with controllable values
const mockContextValues = {
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
marketplaceCollectionsFromClient: undefined as MarketplaceCollection[] | undefined,
marketplaceCollectionPluginsMapFromClient: undefined as Record<string, Plugin[]> | undefined,
isLoading: false,
isSuccessCollections: false,
handleQueryPlugins: vi.fn(),
searchPluginText: '',
filterPluginTags: [] as string[],
page: 1,
handleMoreClick: vi.fn(),
}
// Mock marketplace state hooks with controllable values
const { mockMarketplaceData, mockMoreClick } = vi.hoisted(() => {
return {
mockMarketplaceData: {
plugins: undefined as Plugin[] | undefined,
pluginsTotal: 0,
marketplaceCollections: undefined as MarketplaceCollection[] | undefined,
marketplaceCollectionPluginsMap: undefined as Record<string, Plugin[]> | undefined,
isLoading: false,
page: 1,
},
mockMoreClick: vi.fn(),
}
})
vi.mock('../context', () => ({
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
vi.mock('../state', () => ({
useMarketplaceData: () => mockMarketplaceData,
}))
vi.mock('../atoms', () => ({
useMarketplaceMoreClick: () => mockMoreClick,
}))
// Mock useLocale context
@@ -578,7 +582,7 @@ describe('ListWithCollection', () => {
// View More Button Tests
// ================================
describe('View More Button', () => {
it('should render View More button when collection is searchable and onMoreClick is provided', () => {
it('should render View More button when collection is searchable', () => {
const collections = [createMockCollection({
name: 'collection-0',
searchable: true,
@@ -587,14 +591,12 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
const onMoreClick = vi.fn()
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>,
)
@@ -609,42 +611,19 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
const onMoreClick = vi.fn()
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>,
)
expect(screen.queryByText('View More')).not.toBeInTheDocument()
})
it('should not render View More button when onMoreClick is not provided', () => {
const collections = [createMockCollection({
name: 'collection-0',
searchable: true,
})]
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={undefined}
/>,
)
expect(screen.queryByText('View More')).not.toBeInTheDocument()
})
it('should call onMoreClick with search_params when View More is clicked', () => {
it('should call moreClick hook with search_params when View More is clicked', () => {
const searchParams: SearchParamsFromCollection = { query: 'test-query', sort_by: 'install_count' }
const collections = [createMockCollection({
name: 'collection-0',
@@ -654,21 +633,19 @@ describe('ListWithCollection', () => {
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
const onMoreClick = vi.fn()
render(
<ListWithCollection
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>,
)
fireEvent.click(screen.getByText('View More'))
expect(onMoreClick).toHaveBeenCalledTimes(1)
expect(onMoreClick).toHaveBeenCalledWith(searchParams)
expect(mockMoreClick).toHaveBeenCalledTimes(1)
expect(mockMoreClick).toHaveBeenCalledWith(searchParams)
})
})
@@ -802,24 +779,15 @@ describe('ListWithCollection', () => {
// ListWrapper Component Tests
// ================================
describe('ListWrapper', () => {
const defaultProps = {
marketplaceCollections: [] as MarketplaceCollection[],
marketplaceCollectionPluginsMap: {} as Record<string, Plugin[]>,
showInstallButton: false,
}
beforeEach(() => {
vi.clearAllMocks()
// Reset context values
mockContextValues.plugins = undefined
mockContextValues.pluginsTotal = 0
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
mockContextValues.isLoading = false
mockContextValues.isSuccessCollections = false
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
mockContextValues.page = 1
// Reset mock data
mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.marketplaceCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
})
// ================================
@@ -827,32 +795,32 @@ describe('ListWrapper', () => {
// ================================
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(document.body).toBeInTheDocument()
})
it('should render with scrollbarGutter style', () => {
const { container } = render(<ListWrapper {...defaultProps} />)
const { container } = render(<ListWrapper />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveStyle({ scrollbarGutter: 'stable' })
})
it('should render Loading component when isLoading is true and page is 1', () => {
mockContextValues.isLoading = true
mockContextValues.page = 1
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
})
it('should not render Loading component when page > 1', () => {
mockContextValues.isLoading = true
mockContextValues.page = 2
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
})
@@ -863,26 +831,26 @@ describe('ListWrapper', () => {
// ================================
describe('Plugins Header', () => {
it('should render plugins result count when plugins are present', () => {
mockContextValues.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 5
mockMarketplaceData.plugins = createMockPluginList(5)
mockMarketplaceData.pluginsTotal = 5
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
})
it('should render SortDropdown when plugins are present', () => {
mockContextValues.plugins = createMockPluginList(1)
mockMarketplaceData.plugins = createMockPluginList(1)
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByTestId('sort-dropdown')).toBeInTheDocument()
})
it('should not render plugins header when plugins is undefined', () => {
mockContextValues.plugins = undefined
mockMarketplaceData.plugins = undefined
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.queryByTestId('sort-dropdown')).not.toBeInTheDocument()
})
@@ -892,197 +860,60 @@ describe('ListWrapper', () => {
// List Rendering Logic Tests
// ================================
describe('List Rendering Logic', () => {
it('should render List when not loading', () => {
mockContextValues.isLoading = false
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
it('should render collections when not loading', () => {
mockMarketplaceData.isLoading = false
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
render(
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
render(<ListWrapper />)
expect(screen.getByText('Collection 0')).toBeInTheDocument()
})
it('should render List when loading but page > 1', () => {
mockContextValues.isLoading = true
mockContextValues.page = 2
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
render(
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
render(<ListWrapper />)
expect(screen.getByText('Collection 0')).toBeInTheDocument()
})
it('should use client collections when available', () => {
const serverCollections = createMockCollectionList(1)
serverCollections[0].label = { 'en-US': 'Server Collection' }
const clientCollections = createMockCollectionList(1)
clientCollections[0].label = { 'en-US': 'Client Collection' }
const serverPluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
const clientPluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
mockContextValues.marketplaceCollectionsFromClient = clientCollections
mockContextValues.marketplaceCollectionPluginsMapFromClient = clientPluginsMap
render(
<ListWrapper
{...defaultProps}
marketplaceCollections={serverCollections}
marketplaceCollectionPluginsMap={serverPluginsMap}
/>,
)
expect(screen.getByText('Client Collection')).toBeInTheDocument()
expect(screen.queryByText('Server Collection')).not.toBeInTheDocument()
})
it('should use server collections when client collections are not available', () => {
const serverCollections = createMockCollectionList(1)
serverCollections[0].label = { 'en-US': 'Server Collection' }
const serverPluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
render(
<ListWrapper
{...defaultProps}
marketplaceCollections={serverCollections}
marketplaceCollectionPluginsMap={serverPluginsMap}
/>,
)
expect(screen.getByText('Server Collection')).toBeInTheDocument()
})
})
// ================================
// Context Integration Tests
// Data Integration Tests
// ================================
describe('Context Integration', () => {
it('should pass plugins from context to List', () => {
const plugins = createMockPluginList(2)
mockContextValues.plugins = plugins
describe('Data Integration', () => {
it('should pass plugins from state to List', () => {
mockMarketplaceData.plugins = createMockPluginList(2)
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByTestId('card-plugin-0')).toBeInTheDocument()
expect(screen.getByTestId('card-plugin-1')).toBeInTheDocument()
})
it('should pass handleMoreClick from context to List', () => {
const mockHandleMoreClick = vi.fn()
mockContextValues.handleMoreClick = mockHandleMoreClick
const collections = [createMockCollection({
it('should show View More button and call moreClick hook', () => {
mockMarketplaceData.marketplaceCollections = [createMockCollection({
name: 'collection-0',
searchable: true,
search_params: { query: 'test' },
})]
const pluginsMap: Record<string, Plugin[]> = {
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
render(
<ListWrapper
{...defaultProps}
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
/>,
)
render(<ListWrapper />)
fireEvent.click(screen.getByText('View More'))
expect(mockHandleMoreClick).toHaveBeenCalled()
})
})
// ================================
// Effect Tests (handleQueryPlugins)
// ================================
describe('handleQueryPlugins Effect', () => {
it('should call handleQueryPlugins when conditions are met', async () => {
const mockHandleQueryPlugins = vi.fn()
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
mockContextValues.isSuccessCollections = true
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
render(<ListWrapper {...defaultProps} />)
await waitFor(() => {
expect(mockHandleQueryPlugins).toHaveBeenCalled()
})
})
it('should not call handleQueryPlugins when client collections exist', async () => {
const mockHandleQueryPlugins = vi.fn()
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
mockContextValues.isSuccessCollections = true
mockContextValues.marketplaceCollectionsFromClient = createMockCollectionList(1)
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
render(<ListWrapper {...defaultProps} />)
// Give time for effect to run
await waitFor(() => {
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
})
})
it('should not call handleQueryPlugins when search text exists', async () => {
const mockHandleQueryPlugins = vi.fn()
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
mockContextValues.isSuccessCollections = true
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.searchPluginText = 'search text'
mockContextValues.filterPluginTags = []
render(<ListWrapper {...defaultProps} />)
await waitFor(() => {
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
})
})
it('should not call handleQueryPlugins when filter tags exist', async () => {
const mockHandleQueryPlugins = vi.fn()
mockContextValues.handleQueryPlugins = mockHandleQueryPlugins
mockContextValues.isSuccessCollections = true
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = ['tag1']
render(<ListWrapper {...defaultProps} />)
await waitFor(() => {
expect(mockHandleQueryPlugins).not.toHaveBeenCalled()
})
expect(mockMoreClick).toHaveBeenCalled()
})
})
@@ -1090,32 +921,32 @@ describe('ListWrapper', () => {
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty plugins array from context', () => {
mockContextValues.plugins = []
mockContextValues.pluginsTotal = 0
it('should handle empty plugins array', () => {
mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
})
it('should handle large pluginsTotal', () => {
mockContextValues.plugins = createMockPluginList(10)
mockContextValues.pluginsTotal = 10000
mockMarketplaceData.plugins = createMockPluginList(10)
mockMarketplaceData.pluginsTotal = 10000
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
expect(screen.getByText('10000 plugins found')).toBeInTheDocument()
})
it('should handle both loading and has plugins', () => {
mockContextValues.isLoading = true
mockContextValues.page = 2
mockContextValues.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 50
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
mockMarketplaceData.plugins = createMockPluginList(5)
mockMarketplaceData.pluginsTotal = 50
render(<ListWrapper {...defaultProps} />)
render(<ListWrapper />)
// Should show plugins header and list
expect(screen.getByText('50 plugins found')).toBeInTheDocument()
@@ -1428,106 +1259,72 @@ describe('CardWrapper (via List integration)', () => {
describe('Combined Workflows', () => {
beforeEach(() => {
vi.clearAllMocks()
mockContextValues.plugins = undefined
mockContextValues.pluginsTotal = 0
mockContextValues.isLoading = false
mockContextValues.page = 1
mockContextValues.marketplaceCollectionsFromClient = undefined
mockContextValues.marketplaceCollectionPluginsMapFromClient = undefined
mockMarketplaceData.plugins = undefined
mockMarketplaceData.pluginsTotal = 0
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
mockMarketplaceData.marketplaceCollections = undefined
mockMarketplaceData.marketplaceCollectionPluginsMap = undefined
})
it('should transition from loading to showing collections', async () => {
mockContextValues.isLoading = true
mockContextValues.page = 1
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 1
const { rerender } = render(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
const { rerender } = render(<ListWrapper />)
expect(screen.getByTestId('loading-component')).toBeInTheDocument()
// Simulate loading complete
mockContextValues.isLoading = false
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
mockMarketplaceData.isLoading = false
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
mockContextValues.marketplaceCollectionsFromClient = collections
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
rerender(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
rerender(<ListWrapper />)
expect(screen.queryByTestId('loading-component')).not.toBeInTheDocument()
expect(screen.getByText('Collection 0')).toBeInTheDocument()
})
it('should transition from collections to search results', async () => {
const collections = createMockCollectionList(1)
const pluginsMap: Record<string, Plugin[]> = {
mockMarketplaceData.marketplaceCollections = createMockCollectionList(1)
mockMarketplaceData.marketplaceCollectionPluginsMap = {
'collection-0': createMockPluginList(1),
}
mockContextValues.marketplaceCollectionsFromClient = collections
mockContextValues.marketplaceCollectionPluginsMapFromClient = pluginsMap
const { rerender } = render(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
const { rerender } = render(<ListWrapper />)
expect(screen.getByText('Collection 0')).toBeInTheDocument()
// Simulate search results
mockContextValues.plugins = createMockPluginList(5)
mockContextValues.pluginsTotal = 5
mockMarketplaceData.plugins = createMockPluginList(5)
mockMarketplaceData.pluginsTotal = 5
rerender(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
rerender(<ListWrapper />)
expect(screen.queryByText('Collection 0')).not.toBeInTheDocument()
expect(screen.getByText('5 plugins found')).toBeInTheDocument()
})
it('should handle empty search results', () => {
mockContextValues.plugins = []
mockContextValues.pluginsTotal = 0
mockMarketplaceData.plugins = []
mockMarketplaceData.pluginsTotal = 0
render(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
render(<ListWrapper />)
expect(screen.getByTestId('empty-component')).toBeInTheDocument()
expect(screen.getByText('0 plugins found')).toBeInTheDocument()
})
it('should support pagination (page > 1)', () => {
mockContextValues.plugins = createMockPluginList(40)
mockContextValues.pluginsTotal = 80
mockContextValues.isLoading = true
mockContextValues.page = 2
mockMarketplaceData.plugins = createMockPluginList(40)
mockMarketplaceData.pluginsTotal = 80
mockMarketplaceData.isLoading = true
mockMarketplaceData.page = 2
render(
<ListWrapper
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
/>,
)
render(<ListWrapper />)
// Should show existing results while loading more
expect(screen.getByText('80 plugins found')).toBeInTheDocument()
@@ -1542,9 +1339,9 @@ describe('Combined Workflows', () => {
describe('Accessibility', () => {
beforeEach(() => {
vi.clearAllMocks()
mockContextValues.plugins = undefined
mockContextValues.isLoading = false
mockContextValues.page = 1
mockMarketplaceData.plugins = undefined
mockMarketplaceData.isLoading = false
mockMarketplaceData.page = 1
})
it('should have semantic structure with collections', () => {
@@ -1573,13 +1370,11 @@ describe('Accessibility', () => {
const pluginsMap: Record<string, Plugin[]> = {
'collection-0': createMockPluginList(1),
}
const onMoreClick = vi.fn()
render(
<ListWithCollection
marketplaceCollections={collections}
marketplaceCollectionPluginsMap={pluginsMap}
onMoreClick={onMoreClick}
/>,
)

View File

@@ -13,7 +13,6 @@ type ListProps = {
showInstallButton?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: () => void
emptyClassName?: string
}
const List = ({
@@ -23,7 +22,6 @@ const List = ({
showInstallButton,
cardContainerClassName,
cardRender,
onMoreClick,
emptyClassName,
}: ListProps) => {
return (
@@ -36,7 +34,6 @@ const List = ({
showInstallButton={showInstallButton}
cardContainerClassName={cardContainerClassName}
cardRender={cardRender}
onMoreClick={onMoreClick}
/>
)
}

View File

@@ -1,12 +1,12 @@
'use client'
import type { MarketplaceCollection } from '../types'
import type { SearchParamsFromCollection } from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import { useLocale, useTranslation } from '#i18n'
import { RiArrowRightSLine } from '@remixicon/react'
import { getLanguage } from '@/i18n-config/language'
import { cn } from '@/utils/classnames'
import { useMarketplaceMoreClick } from '../atoms'
import CardWrapper from './card-wrapper'
type ListWithCollectionProps = {
@@ -15,7 +15,6 @@ type ListWithCollectionProps = {
showInstallButton?: boolean
cardContainerClassName?: string
cardRender?: (plugin: Plugin) => React.JSX.Element | null
onMoreClick?: (searchParams?: SearchParamsFromCollection) => void
}
const ListWithCollection = ({
marketplaceCollections,
@@ -23,10 +22,10 @@ const ListWithCollection = ({
showInstallButton,
cardContainerClassName,
cardRender,
onMoreClick,
}: ListWithCollectionProps) => {
const { t } = useTranslation()
const locale = useLocale()
const onMoreClick = useMarketplaceMoreClick()
return (
<>
@@ -44,10 +43,10 @@ const ListWithCollection = ({
<div className="system-xs-regular text-text-tertiary">{collection.description[getLanguage(locale)]}</div>
</div>
{
collection.searchable && onMoreClick && (
collection.searchable && (
<div
className="system-xs-medium flex cursor-pointer items-center text-text-accent "
onClick={() => onMoreClick?.(collection.search_params)}
onClick={() => onMoreClick(collection.search_params)}
>
{t('marketplace.viewMore', { ns: 'plugin' })}
<RiArrowRightSLine className="h-4 w-4" />

View File

@@ -1,46 +1,26 @@
'use client'
import type { Plugin } from '../../types'
import type { MarketplaceCollection } from '../types'
import { useTranslation } from '#i18n'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useMarketplaceContext } from '../context'
import SortDropdown from '../sort-dropdown'
import { useMarketplaceData } from '../state'
import List from './index'
type ListWrapperProps = {
marketplaceCollections: MarketplaceCollection[]
marketplaceCollectionPluginsMap: Record<string, Plugin[]>
showInstallButton?: boolean
}
const ListWrapper = ({
marketplaceCollections,
marketplaceCollectionPluginsMap,
showInstallButton,
}: ListWrapperProps) => {
const { t } = useTranslation()
const plugins = useMarketplaceContext(v => v.plugins)
const pluginsTotal = useMarketplaceContext(v => v.pluginsTotal)
const marketplaceCollectionsFromClient = useMarketplaceContext(v => v.marketplaceCollectionsFromClient)
const marketplaceCollectionPluginsMapFromClient = useMarketplaceContext(v => v.marketplaceCollectionPluginsMapFromClient)
const isLoading = useMarketplaceContext(v => v.isLoading)
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const page = useMarketplaceContext(v => v.page)
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
useEffect(() => {
if (
!marketplaceCollectionsFromClient?.length
&& isSuccessCollections
&& !searchPluginText
&& !filterPluginTags.length
) {
handleQueryPlugins()
}
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
const {
plugins,
pluginsTotal,
marketplaceCollections,
marketplaceCollectionPluginsMap,
isLoading,
page,
} = useMarketplaceData()
return (
<div
@@ -66,11 +46,10 @@ const ListWrapper = ({
{
(!isLoading || page > 1) && (
<List
marketplaceCollections={marketplaceCollectionsFromClient || marketplaceCollections}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMapFromClient || marketplaceCollectionPluginsMap}
marketplaceCollections={marketplaceCollections || []}
marketplaceCollectionPluginsMap={marketplaceCollectionPluginsMap || {}}
plugins={plugins}
showInstallButton={showInstallButton}
onMoreClick={handleMoreClick}
/>
)
}

View File

@@ -1,4 +1,5 @@
'use client'
import type { ActivePluginType } from './constants'
import { useTranslation } from '#i18n'
import {
RiArchive2Line,
@@ -8,35 +9,27 @@ import {
RiPuzzle2Line,
RiSpeakAiLine,
} from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useSetAtom } from 'jotai'
import { Trigger as TriggerIcon } from '@/app/components/base/icons/src/vender/plugin'
import { cn } from '@/utils/classnames'
import { PluginCategoryEnum } from '../types'
import { useMarketplaceContext } from './context'
import { searchModeAtom, useActivePluginType } from './atoms'
import { PLUGIN_CATEGORY_WITH_COLLECTIONS, PLUGIN_TYPE_SEARCH_MAP } from './constants'
export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all',
model: PluginCategoryEnum.model,
tool: PluginCategoryEnum.tool,
agent: PluginCategoryEnum.agent,
extension: PluginCategoryEnum.extension,
datasource: PluginCategoryEnum.datasource,
trigger: PluginCategoryEnum.trigger,
bundle: 'bundle',
}
type PluginTypeSwitchProps = {
className?: string
showSearchParams?: boolean
}
const PluginTypeSwitch = ({
className,
showSearchParams,
}: PluginTypeSwitchProps) => {
const { t } = useTranslation()
const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const [activePluginType, handleActivePluginTypeChange] = useActivePluginType()
const setSearchMode = useSetAtom(searchModeAtom)
const options = [
const options: Array<{
value: ActivePluginType
text: string
icon: React.ReactNode | null
}> = [
{
value: PLUGIN_TYPE_SEARCH_MAP.all,
text: t('category.all', { ns: 'plugin' }),
@@ -79,23 +72,6 @@ const PluginTypeSwitch = ({
},
]
const handlePopState = useCallback(() => {
if (!showSearchParams)
return
// nuqs handles popstate automatically
const url = new URL(window.location.href)
const category = url.searchParams.get('category') || PLUGIN_TYPE_SEARCH_MAP.all
handleActivePluginTypeChange(category)
}, [showSearchParams, handleActivePluginTypeChange])
useEffect(() => {
// nuqs manages popstate internally, but we keep this for URL sync
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
}, [handlePopState])
return (
<div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
@@ -112,6 +88,9 @@ const PluginTypeSwitch = ({
)}
onClick={() => {
handleActivePluginTypeChange(option.value)
if (PLUGIN_CATEGORY_WITH_COLLECTIONS.has(option.value)) {
setSearchMode(null)
}
}}
>
{option.icon}

View File

@@ -0,0 +1,38 @@
import type { CollectionsAndPluginsSearchParams, PluginsSearchParams } from './types'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'
// TODO: Avoid manual maintenance of query keys and better service management,
// https://github.com/langgenius/dify/issues/30342
export const marketplaceKeys = {
all: ['marketplace'] as const,
collections: (params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collections', params] as const,
collectionPlugins: (collectionId: string, params?: CollectionsAndPluginsSearchParams) => [...marketplaceKeys.all, 'collectionPlugins', collectionId, params] as const,
plugins: (params?: PluginsSearchParams) => [...marketplaceKeys.all, 'plugins', params] as const,
}
export function useMarketplaceCollectionsAndPlugins(
collectionsParams: CollectionsAndPluginsSearchParams,
) {
return useQuery({
queryKey: marketplaceKeys.collections(collectionsParams),
queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(collectionsParams, { signal }),
})
}
export function useMarketplacePlugins(
queryParams: PluginsSearchParams | undefined,
) {
return useInfiniteQuery({
queryKey: marketplaceKeys.plugins(queryParams),
queryFn: ({ pageParam = 1, signal }) => getMarketplacePlugins(queryParams, pageParam, signal),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.pageSize
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: queryParams !== undefined,
})
}

View File

@@ -26,16 +26,19 @@ vi.mock('#i18n', () => ({
}),
}))
// Mock useMarketplaceContext
const mockContextValues = {
searchPluginText: '',
handleSearchPluginTextChange: vi.fn(),
filterPluginTags: [] as string[],
handleFilterPluginTagsChange: vi.fn(),
}
// Mock marketplace state hooks
const { mockSearchPluginText, mockHandleSearchPluginTextChange, mockFilterPluginTags, mockHandleFilterPluginTagsChange } = vi.hoisted(() => {
return {
mockSearchPluginText: '',
mockHandleSearchPluginTextChange: vi.fn(),
mockFilterPluginTags: [] as string[],
mockHandleFilterPluginTagsChange: vi.fn(),
}
})
vi.mock('../context', () => ({
useMarketplaceContext: (selector: (v: typeof mockContextValues) => unknown) => selector(mockContextValues),
vi.mock('../atoms', () => ({
useSearchPluginText: () => [mockSearchPluginText, mockHandleSearchPluginTextChange],
useFilterPluginTags: () => [mockFilterPluginTags, mockHandleFilterPluginTagsChange],
}))
// Mock useTags hook
@@ -430,9 +433,6 @@ describe('SearchBoxWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpenState = false
// Reset context values
mockContextValues.searchPluginText = ''
mockContextValues.filterPluginTags = []
})
describe('Rendering', () => {
@@ -456,28 +456,14 @@ describe('SearchBoxWrapper', () => {
})
})
describe('Context Integration', () => {
it('should use searchPluginText from context', () => {
mockContextValues.searchPluginText = 'context search'
render(<SearchBoxWrapper />)
expect(screen.getByDisplayValue('context search')).toBeInTheDocument()
})
describe('Hook Integration', () => {
it('should call handleSearchPluginTextChange when search changes', () => {
render(<SearchBoxWrapper />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new search' } })
expect(mockContextValues.handleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
it('should use filterPluginTags from context', () => {
mockContextValues.filterPluginTags = ['agent', 'rag']
render(<SearchBoxWrapper />)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(mockHandleSearchPluginTextChange).toHaveBeenCalledWith('new search')
})
})

View File

@@ -1,15 +1,13 @@
'use client'
import { useTranslation } from '#i18n'
import { useMarketplaceContext } from '../context'
import { useFilterPluginTags, useSearchPluginText } from '../atoms'
import SearchBox from './index'
const SearchBoxWrapper = () => {
const { t } = useTranslation()
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
const [searchPluginText, handleSearchPluginTextChange] = useSearchPluginText()
const [filterPluginTags, handleFilterPluginTagsChange] = useFilterPluginTags()
return (
<SearchBox

View File

@@ -0,0 +1,9 @@
import type { ActivePluginType } from './constants'
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
export const marketplaceSearchParamsParsers = {
category: parseAsStringEnum<ActivePluginType>(Object.values(PLUGIN_TYPE_SEARCH_MAP) as ActivePluginType[]).withDefault('all').withOptions({ history: 'replace', clearOnDefault: false }),
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
}

View File

@@ -1,4 +1,3 @@
import type { MarketplaceContextValue } from '../context'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -28,18 +27,12 @@ vi.mock('#i18n', () => ({
}),
}))
// Mock marketplace context with controllable values
let mockSort = { sortBy: 'install_count', sortOrder: 'DESC' }
// Mock marketplace atoms with controllable values
let mockSort: { sortBy: string, sortOrder: string } = { sortBy: 'install_count', sortOrder: 'DESC' }
const mockHandleSortChange = vi.fn()
vi.mock('../context', () => ({
useMarketplaceContext: (selector: (value: MarketplaceContextValue) => unknown) => {
const contextValue = {
sort: mockSort,
handleSortChange: mockHandleSortChange,
} as unknown as MarketplaceContextValue
return selector(contextValue)
},
vi.mock('../atoms', () => ({
useMarketplaceSort: () => [mockSort, mockHandleSortChange],
}))
// Mock portal component with controllable open state

View File

@@ -10,7 +10,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useMarketplaceContext } from '../context'
import { useMarketplaceSort } from '../atoms'
const SortDropdown = () => {
const { t } = useTranslation()
@@ -36,8 +36,7 @@ const SortDropdown = () => {
text: t('marketplace.sortOption.firstReleased', { ns: 'plugin' }),
},
]
const sort = useMarketplaceContext(v => v.sort)
const handleSortChange = useMarketplaceContext(v => v.handleSortChange)
const [sort, handleSortChange] = useMarketplaceSort()
const [open, setOpen] = useState(false)
const selectedOption = options.find(option => option.value === sort.sortBy && option.order === sort.sortOrder) ?? options[0]

View File

@@ -0,0 +1,54 @@
import type { PluginsSearchParams } from './types'
import { useDebounce } from 'ahooks'
import { useCallback, useMemo } from 'react'
import { useActivePluginType, useFilterPluginTags, useMarketplaceSearchMode, useMarketplaceSortValue, useSearchPluginText } from './atoms'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
import { useMarketplaceContainerScroll } from './hooks'
import { useMarketplaceCollectionsAndPlugins, useMarketplacePlugins } from './query'
import { getCollectionsParams, getMarketplaceListFilterType } from './utils'
export function useMarketplaceData() {
const [searchPluginTextOriginal] = useSearchPluginText()
const searchPluginText = useDebounce(searchPluginTextOriginal, { wait: 500 })
const [filterPluginTags] = useFilterPluginTags()
const [activePluginType] = useActivePluginType()
const collectionsQuery = useMarketplaceCollectionsAndPlugins(
getCollectionsParams(activePluginType),
)
const sort = useMarketplaceSortValue()
const isSearchMode = useMarketplaceSearchMode()
const queryParams = useMemo((): PluginsSearchParams | undefined => {
if (!isSearchMode)
return undefined
return {
query: searchPluginText,
category: activePluginType === PLUGIN_TYPE_SEARCH_MAP.all ? undefined : activePluginType,
tags: filterPluginTags,
sortBy: sort.sortBy,
sortOrder: sort.sortOrder,
type: getMarketplaceListFilterType(activePluginType),
}
}, [isSearchMode, searchPluginText, activePluginType, filterPluginTags, sort])
const pluginsQuery = useMarketplacePlugins(queryParams)
const { hasNextPage, fetchNextPage, isFetching } = pluginsQuery
const handlePageChange = useCallback(() => {
if (hasNextPage && !isFetching)
fetchNextPage()
}, [fetchNextPage, hasNextPage, isFetching])
// Scroll pagination
useMarketplaceContainerScroll(handlePageChange)
return {
marketplaceCollections: collectionsQuery.data?.marketplaceCollections,
marketplaceCollectionPluginsMap: collectionsQuery.data?.marketplaceCollectionPluginsMap,
plugins: pluginsQuery.data?.pages.flatMap(page => page.plugins),
pluginsTotal: pluginsQuery.data?.pages[0]?.total,
page: pluginsQuery.data?.pages.length || 1,
isLoading: collectionsQuery.isLoading || pluginsQuery.isLoading,
}
}

View File

@@ -6,12 +6,10 @@ import SearchBoxWrapper from './search-box/search-box-wrapper'
type StickySearchAndSwitchWrapperProps = {
pluginTypeSwitchClassName?: string
showSearchParams?: boolean
}
const StickySearchAndSwitchWrapper = ({
pluginTypeSwitchClassName,
showSearchParams,
}: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
@@ -24,9 +22,7 @@ const StickySearchAndSwitchWrapper = ({
)}
>
<SearchBoxWrapper />
<PluginTypeSwitch
showSearchParams={showSearchParams}
/>
<PluginTypeSwitch />
</div>
)
}

View File

@@ -1,16 +1,19 @@
import type { ActivePluginType } from './constants'
import type {
CollectionsAndPluginsSearchParams,
MarketplaceCollection,
PluginsSearchParams,
} from '@/app/components/plugins/marketplace/types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import {
APP_VERSION,
IS_MARKETPLACE,
MARKETPLACE_API_PREFIX,
} from '@/config'
import { postMarketplace } from '@/service/base'
import { getMarketplaceUrl } from '@/utils/var'
import { PLUGIN_TYPE_SEARCH_MAP } from './plugin-type-switch'
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
type MarketplaceFetchOptions = {
signal?: AbortSignal
@@ -26,12 +29,13 @@ export const getPluginIconInMarketplace = (plugin: Plugin) => {
return `${MARKETPLACE_API_PREFIX}/plugins/${plugin.org}/${plugin.name}/icon`
}
export const getFormattedPlugin = (bundle: any) => {
export const getFormattedPlugin = (bundle: Plugin): Plugin => {
if (bundle.type === 'bundle') {
return {
...bundle,
icon: getPluginIconInMarketplace(bundle),
brief: bundle.description,
// @ts-expect-error I do not have enough information
label: bundle.labels,
}
}
@@ -129,6 +133,64 @@ export const getMarketplaceCollectionsAndPlugins = async (
}
}
export const getMarketplacePlugins = async (
queryParams: PluginsSearchParams | undefined,
pageParam: number,
signal?: AbortSignal,
) => {
if (!queryParams) {
return {
plugins: [] as Plugin[],
total: 0,
page: 1,
pageSize: 40,
}
}
const {
query,
sortBy,
sortOrder,
category,
tags,
type,
pageSize = 40,
} = queryParams
const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
try {
const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
body: {
page: pageParam,
page_size: pageSize,
query,
sort_by: sortBy,
sort_order: sortOrder,
category: category !== 'all' ? category : '',
tags,
type,
},
signal,
})
const resPlugins = res.data.bundles || res.data.plugins || []
return {
plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)),
total: res.data.total,
page: pageParam,
pageSize,
}
}
catch {
return {
plugins: [],
total: 0,
page: pageParam,
pageSize,
}
}
}
export const getMarketplaceListCondition = (pluginType: string) => {
if ([PluginCategoryEnum.tool, PluginCategoryEnum.agent, PluginCategoryEnum.model, PluginCategoryEnum.datasource, PluginCategoryEnum.trigger].includes(pluginType as PluginCategoryEnum))
return `category=${pluginType}`
@@ -142,7 +204,7 @@ export const getMarketplaceListCondition = (pluginType: string) => {
return ''
}
export const getMarketplaceListFilterType = (category: string) => {
export const getMarketplaceListFilterType = (category: ActivePluginType) => {
if (category === PLUGIN_TYPE_SEARCH_MAP.all)
return undefined
@@ -151,3 +213,14 @@ export const getMarketplaceListFilterType = (category: string) => {
return 'plugin'
}
export function getCollectionsParams(category: ActivePluginType): CollectionsAndPluginsSearchParams {
if (category === PLUGIN_TYPE_SEARCH_MAP.all) {
return {}
}
return {
category,
condition: getMarketplaceListCondition(category),
type: getMarketplaceListFilterType(category),
}
}

View File

@@ -27,7 +27,7 @@ import { cn } from '@/utils/classnames'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
import InstallFromMarketplace from '../install-plugin/install-from-marketplace'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/plugin-type-switch'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import {
PluginPageContextProvider,
usePluginPageContext,

View File

@@ -262,7 +262,7 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({
}))
// Mock PLUGIN_TYPE_SEARCH_MAP
vi.mock('../../marketplace/plugin-type-switch', () => ({
vi.mock('../../marketplace/constants', () => ({
PLUGIN_TYPE_SEARCH_MAP: {
all: 'all',
model: 'model',

View File

@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import type { ActivePluginType } from '../../marketplace/constants'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -12,7 +13,7 @@ import {
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import { useInstalledPluginList } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/plugin-type-switch'
import { PLUGIN_TYPE_SEARCH_MAP } from '../../marketplace/constants'
import { PluginSource } from '../../types'
import NoDataPlaceholder from './no-data-placeholder'
import ToolItem from './tool-item'
@@ -73,7 +74,7 @@ const ToolPicker: FC<Props> = ({
},
]
const [pluginType, setPluginType] = useState(PLUGIN_TYPE_SEARCH_MAP.all)
const [pluginType, setPluginType] = useState<ActivePluginType>(PLUGIN_TYPE_SEARCH_MAP.all)
const [query, setQuery] = useState('')
const [tags, setTags] = useState<string[]>([])
const { data, isLoading } = useInstalledPluginList()

View File

@@ -195,7 +195,7 @@ const RunOnce: FC<IRunOnceProps> = ({
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
<div className="whitespace-pre">{item.json_schema}</div>
<div className="whitespace-pre">{typeof item.json_schema === 'string' ? item.json_schema : JSON.stringify(item.json_schema || '', null, 2)}</div>
}
/>
)}

View File

@@ -48,6 +48,12 @@ const FormItem: FC<Props> = ({
const { t } = useTranslation()
const { type } = payload
const fileSettings = useHooksStore(s => s.configsMap?.fileSettings)
const jsonSchemaPlaceholder = React.useMemo(() => {
const schema = (payload as any)?.json_schema
if (!schema)
return ''
return typeof schema === 'string' ? schema : JSON.stringify(schema, null, 2)
}, [payload])
const handleArrayItemChange = useCallback((index: number) => {
return (newValue: any) => {
@@ -211,7 +217,7 @@ const FormItem: FC<Props> = ({
noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"
placeholder={
<div className="whitespace-pre">{payload.json_schema}</div>
<div className="whitespace-pre">{jsonSchemaPlaceholder}</div>
}
/>
)}

View File

@@ -353,7 +353,7 @@ const formatItem = (
try {
if (type === VarType.object && v.json_schema) {
varRes.children = {
schema: JSON.parse(v.json_schema),
schema: typeof v.json_schema === 'string' ? JSON.parse(v.json_schema) : v.json_schema,
}
}
}

View File

@@ -223,7 +223,7 @@ export type InputVar = {
getVarValueFromDependent?: boolean
hide?: boolean
isFileItem?: boolean
json_schema?: string // for jsonObject type
json_schema?: string | Record<string, any> // for jsonObject type
} & Partial<UploadFileSetting>
export type ModelConfig = {

View File

@@ -0,0 +1,16 @@
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
const STALE_TIME = 1000 * 60 * 30 // 30 minutes
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIME,
},
},
})
}
export const getQueryClientServer = cache(makeQueryClient)

View File

@@ -1,23 +1,27 @@
'use client'
import type { QueryClient } from '@tanstack/react-query'
import type { FC, PropsWithChildren } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
import { TanStackDevtoolsLoader } from '@/app/components/devtools/tanstack/loader'
import { makeQueryClient } from './query-client-server'
const STALE_TIME = 1000 * 60 * 30 // 30 minutes
let browserQueryClient: QueryClient | undefined
const client = new QueryClient({
defaultOptions: {
queries: {
staleTime: STALE_TIME,
},
},
})
function getQueryClient() {
if (typeof window === 'undefined') {
return makeQueryClient()
}
if (!browserQueryClient)
browserQueryClient = makeQueryClient()
return browserQueryClient
}
export const TanstackQueryInitializer: FC<PropsWithChildren> = (props) => {
const { children } = props
export const TanstackQueryInitializer: FC<PropsWithChildren> = ({ children }) => {
const [queryClient] = useState(getQueryClient)
return (
<QueryClientProvider client={client}>
<QueryClientProvider client={queryClient}>
{children}
<TanStackDevtoolsLoader />
</QueryClientProvider>

View File

@@ -8,7 +8,6 @@ import {
PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE,
useAccountSettingModal,
useMarketplaceFilters,
usePluginInstallation,
usePricingModal,
} from './use-query-params'
@@ -302,174 +301,6 @@ describe('useQueryParams hooks', () => {
})
})
// Marketplace filters query behavior.
describe('useMarketplaceFilters', () => {
it('should return default filters when query params are missing', () => {
// Arrange
const { result } = renderWithAdapter(() => useMarketplaceFilters())
// Act
const [filters] = result.current
// Assert
expect(filters.q).toBe('')
expect(filters.category).toBe('all')
expect(filters.tags).toEqual([])
})
it('should parse filters when query params are present', () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=prompt&category=tool&tags=ai,ml',
)
// Act
const [filters] = result.current
// Assert
expect(filters.q).toBe('prompt')
expect(filters.category).toBe('tool')
expect(filters.tags).toEqual(['ai', 'ml'])
})
it('should treat empty tags param as empty array', () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?tags=',
)
// Act
const [filters] = result.current
// Assert
expect(filters.tags).toEqual([])
})
it('should preserve other filters when updating a single field', async () => {
// Arrange
const { result } = renderWithAdapter(
() => useMarketplaceFilters(),
'?category=tool&tags=ai,ml',
)
// Act
act(() => {
result.current[1]({ q: 'search' })
})
// Assert
await waitFor(() => expect(result.current[0].q).toBe('search'))
expect(result.current[0].category).toBe('tool')
expect(result.current[0].tags).toEqual(['ai', 'ml'])
})
it('should clear q param when q is empty', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=search',
)
// Act
act(() => {
result.current[1]({ q: '' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('q')).toBe(false)
})
it('should serialize tags as comma-separated values', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
// Act
act(() => {
result.current[1]({ tags: ['ai', 'ml'] })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('tags')).toBe('ai,ml')
})
it('should remove tags param when list is empty', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?tags=ai,ml',
)
// Act
act(() => {
result.current[1]({ tags: [] })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('tags')).toBe(false)
})
it('should keep category in the URL when set to default', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?category=tool',
)
// Act
act(() => {
result.current[1]({ category: 'all' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.get('category')).toBe('all')
})
it('should clear all marketplace filters when set to null', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => useMarketplaceFilters(),
'?q=search&category=tool&tags=ai,ml',
)
// Act
act(() => {
result.current[1](null)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.searchParams.has('q')).toBe(false)
expect(update.searchParams.has('category')).toBe(false)
expect(update.searchParams.has('tags')).toBe(false)
})
it('should use replace history when updating filters', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => useMarketplaceFilters())
// Act
act(() => {
result.current[1]({ q: 'search' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
expect(update.options.history).toBe('replace')
})
})
// Plugin installation query behavior.
describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => {

View File

@@ -15,7 +15,6 @@
import {
createParser,
parseAsArrayOf,
parseAsString,
useQueryState,
useQueryStates,
@@ -93,39 +92,6 @@ export function useAccountSettingModal<T extends string = string>() {
return [{ isOpen, payload: currentTab }, setState] as const
}
/**
* Marketplace Search Query Parameters
*/
export type MarketplaceFilters = {
q: string // search query
category: string // plugin category
tags: string[] // array of tags
}
/**
* Hook to manage marketplace search/filter state via URL
* Provides atomic updates - all params update together
*
* @example
* const [filters, setFilters] = useMarketplaceFilters()
* setFilters({ q: 'search', category: 'tool', tags: ['ai'] }) // Updates all at once
* setFilters({ q: '' }) // Only updates q, keeps others
* setFilters(null) // Clears all marketplace params
*/
export function useMarketplaceFilters() {
return useQueryStates(
{
q: parseAsString.withDefault(''),
category: parseAsString.withDefault('all').withOptions({ clearOnDefault: false }),
tags: parseAsArrayOf(parseAsString).withDefault([]),
},
{
// Update URL without pushing to history (replaceState behavior)
history: 'replace',
},
)
}
/**
* Plugin Installation Query Parameters
*/

View File

@@ -62,7 +62,7 @@ export type PromptVariable = {
icon?: string
icon_background?: string
hide?: boolean // used in frontend to hide variable
json_schema?: string
json_schema?: string | Record<string, any>
}
export type CompletionParams = {

View File

@@ -10,13 +10,14 @@ import type {
AppVoicesListResponse,
WorkflowDailyConversationsResponse,
} from '@/models/app'
import type { App, AppModeEnum } from '@/types/app'
import type { App } from '@/types/app'
import {
keepPreviousData,
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { AppModeEnum } from '@/types/app'
import { get, post } from './base'
import { useInvalid } from './use-base'
@@ -36,6 +37,16 @@ type DateRangeParams = {
end?: string
}
// Allowed app modes for filtering; defined at module scope to avoid re-creating on every call
const allowedModes = new Set<AppModeEnum | 'all'>([
'all',
AppModeEnum.WORKFLOW,
AppModeEnum.ADVANCED_CHAT,
AppModeEnum.CHAT,
AppModeEnum.AGENT_CHAT,
AppModeEnum.COMPLETION,
])
const normalizeAppListParams = (params: AppListParams) => {
const {
page = 1,
@@ -46,11 +57,13 @@ const normalizeAppListParams = (params: AppListParams) => {
is_created_by_me,
} = params
const safeMode = allowedModes.has((mode as any)) ? mode : undefined
return {
page,
limit,
name,
...(mode && mode !== 'all' ? { mode } : {}),
...(safeMode && safeMode !== 'all' ? { mode: safeMode } : {}),
...(tag_ids?.length ? { tag_ids } : {}),
...(is_created_by_me ? { is_created_by_me } : {}),
}

View File

@@ -66,7 +66,30 @@ export const sanitizeWorkflowDraftPayload = (params: WorkflowDraftSyncParams): W
if (!graph?.nodes?.length)
return params
const sanitizedNodes = graph.nodes.map(node => sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>))
const sanitizedNodes = graph.nodes.map((node) => {
// First sanitize known node types (TriggerPlugin)
const n = sanitizeTriggerPluginNode(node as Node<TriggerPluginNodePayload>) as Node<any>
// Normalize Start node variable json_schema: ensure dict, not string
if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
const next = { ...n, data: { ...n.data } }
next.data.variables = (n.data as any).variables.map((v: any) => {
if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
try {
const obj = JSON.parse(v.json_schema)
return { ...v, json_schema: obj }
}
catch {
return v
}
}
return v
})
return next
}
return n
})
return {
...params,
@@ -126,7 +149,25 @@ export const hydrateWorkflowDraftResponse = (draft: FetchWorkflowDraftResponse):
if (node.data)
removeTempProperties(node.data as Record<string, unknown>)
return hydrateTriggerPluginNode(node)
let n = hydrateTriggerPluginNode(node)
// Normalize Start node variable json_schema to object when loading
if ((n.data as any)?.type === BlockEnum.Start && Array.isArray((n.data as any).variables)) {
const next = { ...n, data: { ...n.data } } as Node<any>
next.data.variables = (n.data as any).variables.map((v: any) => {
if (v && v.type === 'json_object' && typeof v.json_schema === 'string') {
try {
const obj = JSON.parse(v.json_schema)
return { ...v, json_schema: obj }
}
catch {
return v
}
}
return v
})
n = next
}
return n
})
}

View File

@@ -9,6 +9,7 @@ import type {
} from '@/types/workflow'
import { get, post } from './base'
import { getFlowPrefix } from './utils'
import { sanitizeWorkflowDraftPayload } from './workflow-payload'
export const fetchWorkflowDraft = (url: string) => {
return get(url, {}, { silent: true }) as Promise<FetchWorkflowDraftResponse>
@@ -18,7 +19,8 @@ export const syncWorkflowDraft = ({ url, params }: {
url: string
params: Pick<FetchWorkflowDraftResponse, 'graph' | 'features' | 'environment_variables' | 'conversation_variables'>
}) => {
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: params }, { silent: true })
const sanitized = sanitizeWorkflowDraftPayload(params)
return post<CommonResponse & { updated_at: number, hash: string }>(url, { body: sanitized }, { silent: true })
}
export const fetchNodesDefaultConfigs = (url: string) => {