Compare commits

..

4 Commits

Author SHA1 Message Date
yyh
142f94e27a Merge remote-tracking branch 'origin/main' into codex/dify-ui-package-migration 2026-04-03 12:14:22 +08:00
yyh
a1bd929b3c remove 2026-04-02 18:35:02 +08:00
yyh
ffb9ee3e36 fix(web): support lint tooling package exports 2026-04-02 18:29:44 +08:00
yyh
485586f49a feat(web): extract dify ui package 2026-04-02 18:25:16 +08:00
96 changed files with 2459 additions and 2244 deletions

View File

@@ -33,7 +33,6 @@ from core.moderation.base import ModerationError
from core.moderation.input_moderation import InputModeration
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import get_default_root_node_id
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import (
build_bootstrap_variables,
build_system_variables,
@@ -189,11 +188,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=new_inputs)
# init graph
graph_runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.time(),
workflow_id=self._workflow.id,
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.time())
graph = self._init_graph(
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,

View File

@@ -23,7 +23,6 @@ from core.app.entities.app_invoke_entities import (
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
@@ -159,11 +158,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
workflow.graph_dict
)
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
graph_runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow.id,
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
# init graph
graph = self._init_rag_pipeline_graph(

View File

@@ -16,7 +16,6 @@ from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerat
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import get_default_root_node_id
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
@@ -119,11 +118,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict)
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs)
graph_runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=self._workflow.id,
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph = self._init_graph(
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,

View File

@@ -68,7 +68,6 @@ from core.app.entities.queue_entities import (
)
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id, resolve_workflow_node_class
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import (
build_bootstrap_variables,
default_system_variables,
@@ -192,11 +191,7 @@ class WorkflowBasedAppRunner:
environment_variables=workflow.environment_variables,
),
)
graph_runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.time(),
workflow_id=workflow.id,
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.time())
# Determine which type of single node execution and get graph/variable_pool
if single_iteration_run:

View File

@@ -1,123 +0,0 @@
"""Helpers for explicitly wiring GraphRuntimeState collaborators.
GraphOn currently supports lazy construction of several runtime-state
collaborators such as the ready queue, graph execution aggregate, and response
coordinator. Dify initializes those collaborators eagerly so repository code
does not depend on that implicit behavior.
"""
from __future__ import annotations
from contextlib import AbstractContextManager
from graphon.graph import Graph
from graphon.graph_engine.domain.graph_execution import GraphExecution
from graphon.graph_engine.ready_queue import InMemoryReadyQueue
from graphon.graph_engine.response_coordinator import ResponseStreamCoordinator
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.runtime import GraphRuntimeState, VariablePool
def _require_workflow_id(workflow_id: str) -> str:
"""Validate that workflow-scoped runtime collaborators receive a real id."""
if not workflow_id:
raise ValueError("workflow_id must be a non-empty string")
return workflow_id
def create_graph_runtime_state(
*,
variable_pool: VariablePool,
start_at: float,
workflow_id: str,
total_tokens: int = 0,
llm_usage: LLMUsage | None = None,
outputs: dict[str, object] | None = None,
node_run_steps: int = 0,
execution_context: AbstractContextManager[object] | None = None,
) -> GraphRuntimeState:
"""Create a runtime state with explicit non-graph collaborators.
The graph itself is attached later, once node construction has completed and
the final Graph instance exists.
"""
workflow_id = _require_workflow_id(workflow_id)
return GraphRuntimeState(
variable_pool=variable_pool,
start_at=start_at,
total_tokens=total_tokens,
llm_usage=llm_usage or LLMUsage.empty_usage(),
outputs=outputs or {},
node_run_steps=node_run_steps,
ready_queue=InMemoryReadyQueue(),
graph_execution=GraphExecution(workflow_id=workflow_id),
execution_context=execution_context,
)
def ensure_graph_runtime_state_initialized(
graph_runtime_state: GraphRuntimeState,
*,
workflow_id: str,
) -> GraphRuntimeState:
"""Materialize non-graph collaborators when loading legacy or sparse state."""
workflow_id = _require_workflow_id(workflow_id)
if graph_runtime_state._ready_queue is None:
graph_runtime_state._ready_queue = InMemoryReadyQueue()
graph_execution = graph_runtime_state._graph_execution
if graph_execution is None:
graph_runtime_state._graph_execution = GraphExecution(
workflow_id=workflow_id,
)
elif not graph_execution.workflow_id:
graph_execution.workflow_id = workflow_id
elif graph_execution.workflow_id != workflow_id:
raise ValueError("GraphRuntimeState workflow_id does not match graph execution workflow_id")
return graph_runtime_state
def bind_graph_runtime_state_to_graph(
graph_runtime_state: GraphRuntimeState,
graph: Graph,
*,
workflow_id: str,
) -> GraphRuntimeState:
"""Attach graph-scoped collaborators without relying on GraphOn lazy setup."""
ensure_graph_runtime_state_initialized(
graph_runtime_state,
workflow_id=workflow_id,
)
attached_graph = graph_runtime_state._graph
if attached_graph is not None and attached_graph is not graph:
raise ValueError("GraphRuntimeState already attached to a different graph instance")
if graph_runtime_state._response_coordinator is None:
response_coordinator = ResponseStreamCoordinator(
variable_pool=graph_runtime_state.variable_pool,
graph=graph,
)
graph_runtime_state._response_coordinator = response_coordinator
graph_runtime_state.attach_graph(graph)
return graph_runtime_state
def snapshot_graph_runtime_state(
graph_runtime_state: GraphRuntimeState,
*,
workflow_id: str,
) -> str:
"""Serialize runtime state after explicit collaborator initialization."""
ensure_graph_runtime_state_initialized(
graph_runtime_state,
workflow_id=workflow_id,
)
return graph_runtime_state.dumps()

View File

@@ -25,10 +25,6 @@ from core.app.file_access import DatabaseFileAccessController
from core.app.workflow.layers.llm_quota import LLMQuotaLayer
from core.app.workflow.layers.observability import ObservabilityLayer
from core.workflow.node_factory import DifyNodeFactory, is_start_node_type, resolve_workflow_node_class
from core.workflow.runtime_state import (
bind_graph_runtime_state_to_graph,
create_graph_runtime_state,
)
from core.workflow.system_variables import (
default_system_variables,
get_node_creation_preload_selectors,
@@ -76,10 +72,9 @@ class _WorkflowChildEngineBuilder:
variable_pool: VariablePool | None = None,
) -> GraphEngine:
"""Build a child engine with a fresh runtime state and only child-safe layers."""
child_graph_runtime_state = create_graph_runtime_state(
child_graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool if variable_pool is not None else parent_graph_runtime_state.variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow_id,
execution_context=parent_graph_runtime_state.execution_context,
)
node_factory = DifyNodeFactory(
@@ -97,11 +92,6 @@ class _WorkflowChildEngineBuilder:
node_factory=node_factory,
root_node_id=root_node_id,
)
bind_graph_runtime_state_to_graph(
child_graph_runtime_state,
child_graph,
workflow_id=workflow_id,
)
command_channel = InMemoryChannel()
config = GraphEngineConfig()
@@ -162,11 +152,6 @@ class WorkflowEntry:
self.command_channel = command_channel
execution_context = capture_current_context()
graph_runtime_state.execution_context = execution_context
bind_graph_runtime_state_to_graph(
graph_runtime_state,
graph,
workflow_id=workflow_id,
)
self._child_engine_builder = _WorkflowChildEngineBuilder()
self.graph_engine = GraphEngine(
workflow_id=workflow_id,
@@ -259,10 +244,9 @@ class WorkflowEntry:
),
call_depth=0,
)
graph_runtime_state = create_graph_runtime_state(
graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow.id,
execution_context=capture_current_context(),
)
@@ -418,7 +402,7 @@ class WorkflowEntry:
),
call_depth=0,
)
graph_runtime_state = create_graph_runtime_state(
graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
execution_context=capture_current_context(),

View File

@@ -8,7 +8,7 @@ from hashlib import sha256
from typing import Any, TypedDict, cast
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import delete, func, select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -1392,10 +1392,10 @@ class RegisterService:
db.session.add(dify_setup)
db.session.commit()
except Exception as e:
db.session.execute(delete(DifySetup))
db.session.execute(delete(TenantAccountJoin))
db.session.execute(delete(Account))
db.session.execute(delete(Tenant))
db.session.query(DifySetup).delete()
db.session.query(TenantAccountJoin).delete()
db.session.query(Account).delete()
db.session.query(Tenant).delete()
db.session.commit()
logger.exception("Setup account failed, email: %s, name: %s", email, name)
@@ -1496,11 +1496,7 @@ class RegisterService:
TenantService.switch_tenant(account, tenant.id)
else:
TenantService.check_member_permission(tenant, inviter, account, "add")
ta = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
if not ta:
TenantService.create_tenant_member(tenant, account, role)
@@ -1557,18 +1553,21 @@ class RegisterService:
if not invitation_data:
return None
tenant = db.session.scalar(
select(Tenant).where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal").limit(1)
tenant = (
db.session.query(Tenant)
.where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal")
.first()
)
if not tenant:
return None
tenant_account = db.session.execute(
select(Account, TenantAccountJoin.role)
tenant_account = (
db.session.query(Account, TenantAccountJoin.role)
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
).first()
.first()
)
if not tenant_account:
return None

View File

@@ -4,8 +4,6 @@ import uuid
import pandas as pd
logger = logging.getLogger(__name__)
from typing import TypedDict
from sqlalchemy import or_, select
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound
@@ -25,27 +23,6 @@ from tasks.annotation.enable_annotation_reply_task import enable_annotation_repl
from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task
class AnnotationJobStatusDict(TypedDict):
job_id: str
job_status: str
class EmbeddingModelDict(TypedDict):
embedding_provider_name: str
embedding_model_name: str
class AnnotationSettingDict(TypedDict):
id: str
enabled: bool
score_threshold: float
embedding_model: EmbeddingModelDict | dict
class AnnotationSettingDisabledDict(TypedDict):
enabled: bool
class AppAnnotationService:
@classmethod
def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation:
@@ -108,7 +85,7 @@ class AppAnnotationService:
return annotation
@classmethod
def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict:
def enable_app_annotation(cls, args: dict, app_id: str):
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(enable_app_annotation_key)
if cache_result is not None:
@@ -132,7 +109,7 @@ class AppAnnotationService:
return {"job_id": job_id, "job_status": "waiting"}
@classmethod
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
def disable_app_annotation(cls, app_id: str):
_, current_tenant_id = current_account_with_tenant()
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(disable_app_annotation_key)
@@ -590,7 +567,7 @@ class AppAnnotationService:
db.session.commit()
@classmethod
def get_app_annotation_setting_by_app_id(cls, app_id: str) -> AnnotationSettingDict | AnnotationSettingDisabledDict:
def get_app_annotation_setting_by_app_id(cls, app_id: str):
_, current_tenant_id = current_account_with_tenant()
# get app info
app = (
@@ -625,9 +602,7 @@ class AppAnnotationService:
return {"enabled": False}
@classmethod
def update_app_annotation_setting(
cls, app_id: str, annotation_setting_id: str, args: dict
) -> AnnotationSettingDict:
def update_app_annotation_setting(cls, app_id: str, annotation_setting_id: str, args: dict):
current_user, current_tenant_id = current_account_with_tenant()
# get app info
app = (

View File

@@ -25,7 +25,7 @@ from graphon.nodes.human_input.entities import HumanInputNodeData, validate_huma
from graphon.nodes.human_input.enums import HumanInputFormKind
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.start.entities import StartNodeData
from graphon.runtime import VariablePool
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variable_loader import load_into_variable_pool
from graphon.variables import VariableBase
from graphon.variables.input_entities import VariableEntityType
@@ -49,7 +49,6 @@ from core.workflow.human_input_compat import (
)
from core.workflow.node_factory import LATEST_VERSION, get_node_type_classes_mapping, is_start_node_type
from core.workflow.node_runtime import DifyHumanInputNodeRuntime, apply_dify_debug_email_recipient
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables, default_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
@@ -1147,10 +1146,9 @@ class WorkflowService:
),
call_depth=0,
)
graph_runtime_state = create_graph_runtime_state(
graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow.id,
)
node = HumanInputNode(
id=node_config["id"],

View File

@@ -28,7 +28,7 @@ from graphon.graph_engine.entities.commands import GraphEngineCommand
from graphon.graph_engine.layers.base import GraphEngineLayerNotInitializedError
from graphon.graph_events import GraphRunPausedEvent
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.runtime import ReadOnlyGraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from sqlalchemy import Engine, delete, select
from sqlalchemy.orm import Session
@@ -38,7 +38,6 @@ from core.app.layers.pause_state_persist_layer import (
PauseStatePersistenceLayer,
WorkflowResumptionContext,
)
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import build_system_variables
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
@@ -204,13 +203,11 @@ class TestPauseStatePersistenceLayerTestContainers:
node_run_steps: int = 0,
variables: dict[tuple[str, str], object] | None = None,
workflow_run_id: str | None = None,
workflow_id: str | None = None,
) -> ReadOnlyGraphRuntimeState:
"""Create a real GraphRuntimeState for testing."""
start_at = time()
execution_id = workflow_run_id or getattr(self, "test_workflow_run_id", None) or str(uuid.uuid4())
resolved_workflow_id = workflow_id or getattr(self, "test_workflow_id", None) or str(uuid.uuid4())
# Create variable pool
variable_pool = VariablePool(system_variables=build_system_variables(workflow_execution_id=execution_id))
@@ -222,10 +219,9 @@ class TestPauseStatePersistenceLayerTestContainers:
llm_usage = LLMUsage.empty_usage()
# Create graph runtime state
graph_runtime_state = create_graph_runtime_state(
graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool,
start_at=start_at,
workflow_id=resolved_workflow_id,
total_tokens=total_tokens,
llm_usage=llm_usage,
outputs=outputs or {},

View File

@@ -26,11 +26,6 @@ from core.repositories.human_input_repository import HumanInputFormEntity, Human
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.runtime_state import (
bind_graph_runtime_state_to_graph,
create_graph_runtime_state,
snapshot_graph_runtime_state,
)
from core.workflow.system_variables import build_system_variables
from libs.datetime_utils import naive_utc_now
from models import Account
@@ -81,11 +76,7 @@ def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: s
user_inputs={},
conversation_variables=[],
)
return create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow_id,
)
return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
def _build_graph(
@@ -294,11 +285,6 @@ class TestHumanInputResumeNodeExecutionIntegration:
)
def _run_graph(self, graph: Graph, runtime_state: GraphRuntimeState, execution_id: str) -> None:
bind_graph_runtime_state_to_graph(
runtime_state,
graph,
workflow_id=self.workflow.id,
)
engine = GraphEngine(
workflow_id=self.workflow.id,
graph=graph,
@@ -328,10 +314,7 @@ class TestHumanInputResumeNodeExecutionIntegration:
)
self._run_graph(paused_graph, runtime_state, execution_id)
snapshot = snapshot_graph_runtime_state(
runtime_state,
workflow_id=self.workflow.id,
)
snapshot = runtime_state.dumps()
resumed_state = GraphRuntimeState.from_snapshot(snapshot)
resume_repo = _mock_form_repository_with_submission(action_id="continue")
resumed_graph = _build_graph(

View File

@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import HumanInputNodeData
from graphon.runtime import VariablePool
from graphon.runtime import GraphRuntimeState, VariablePool
from configs import dify_config
from core.app.app_config.entities import WorkflowUIBasedAppConfig
@@ -19,7 +19,6 @@ from core.workflow.human_input_compat import (
ExternalRecipient,
MemberRecipient,
)
from core.workflow.runtime_state import create_graph_runtime_state, snapshot_graph_runtime_state
from extensions.ext_storage import storage
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
@@ -137,11 +136,7 @@ def _create_workflow_pause_state(
)
db_session_with_containers.add(workflow_run)
runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=0.0,
workflow_id=workflow_id,
)
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
resumption_context = WorkflowResumptionContext(
generate_entity={
"type": AppMode.WORKFLOW,
@@ -161,10 +156,7 @@ def _create_workflow_pause_state(
workflow_execution_id=workflow_run_id,
),
},
serialized_graph_runtime_state=snapshot_graph_runtime_state(
runtime_state,
workflow_id=workflow_id,
),
serialized_graph_runtime_state=runtime_state.dumps(),
)
state_object_key = f"workflow_pause_states/{workflow_run_id}.json"

View File

@@ -30,11 +30,6 @@ from core.app.apps.advanced_chat import app_generator as adv_app_gen_module
from core.app.apps.workflow import app_generator as wf_app_gen_module
from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.node_factory import DifyNodeFactory
from core.workflow.runtime_state import (
bind_graph_runtime_state_to_graph,
create_graph_runtime_state,
snapshot_graph_runtime_state,
)
from core.workflow.system_variables import build_system_variables
from tests.workflow_test_utils import build_test_graph_init_params
@@ -173,21 +168,12 @@ def _build_runtime_state(run_id: str) -> GraphRuntimeState:
conversation_variables=[],
)
variable_pool.add(("sys", "workflow_run_id"), run_id)
return create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id="workflow",
)
return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
def _run_with_optional_pause(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> list[GraphEngineEvent]:
command_channel = InMemoryChannel()
graph = _build_graph(runtime_state, pause_on=pause_on)
bind_graph_runtime_state_to_graph(
runtime_state,
graph,
workflow_id="workflow",
)
engine = GraphEngine(
workflow_id="workflow",
graph=graph,
@@ -218,10 +204,7 @@ def test_workflow_app_pause_resume_matches_baseline(mocker):
paused_events = _run_with_optional_pause(paused_state, pause_on="tool_a")
assert isinstance(paused_events[-1], GraphRunPausedEvent)
paused_nodes = _node_successes(paused_events)
snapshot = snapshot_graph_runtime_state(
paused_state,
workflow_id="workflow",
)
snapshot = paused_state.dumps()
resumed_state = GraphRuntimeState.from_snapshot(snapshot)
@@ -261,10 +244,7 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker):
paused_events = _run_with_optional_pause(paused_state, pause_on="tool_a")
assert isinstance(paused_events[-1], GraphRunPausedEvent)
paused_nodes = _node_successes(paused_events)
snapshot = snapshot_graph_runtime_state(
paused_state,
workflow_id="workflow",
)
snapshot = paused_state.dumps()
resumed_state = GraphRuntimeState.from_snapshot(snapshot)
@@ -301,12 +281,7 @@ def test_resume_emits_resumption_start_reason(mocker) -> None:
initial_start = next(event for event in paused_events if isinstance(event, GraphRunStartedEvent))
assert initial_start.reason == WorkflowStartReason.INITIAL
resumed_state = GraphRuntimeState.from_snapshot(
snapshot_graph_runtime_state(
paused_state,
workflow_id="workflow",
)
)
resumed_state = GraphRuntimeState.from_snapshot(paused_state.dumps())
resumed_events = _run_with_optional_pause(resumed_state, pause_on=None)
resume_start = next(event for event in resumed_events if isinstance(event, GraphRunStartedEvent))
assert resume_start.reason == WorkflowStartReason.RESUMPTION

View File

@@ -27,11 +27,10 @@ from graphon.graph_events import (
NodeRunSucceededEvent,
)
from graphon.node_events import NodeRunResult
from graphon.runtime import ReadOnlyGraphRuntimeStateWrapper, VariablePool
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.workflow.runtime_state import create_graph_runtime_state
from core.workflow.system_variables import SystemVariableKey, build_system_variables
@@ -61,11 +60,7 @@ def _make_layer(
workflow_execution_id="run-id",
conversation_id="conv-id",
)
runtime_state = create_graph_runtime_state(
variable_pool=VariablePool(system_variables=system_variables),
start_at=0.0,
workflow_id="workflow-id",
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(system_variables=system_variables), start_at=0.0)
read_only_state = ReadOnlyGraphRuntimeStateWrapper(runtime_state)
application_generate_entity = WorkflowAppGenerateEntity.model_construct(

View File

@@ -622,8 +622,7 @@ class MockIterationNode(MockNodeMixin, IterationNode):
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import InMemoryChannel
from core.workflow.runtime_state import bind_graph_runtime_state_to_graph, create_graph_runtime_state
from graphon.runtime import GraphRuntimeState
# Import our MockNodeFactory instead of DifyNodeFactory
from .test_mock_factory import MockNodeFactory
@@ -644,10 +643,9 @@ class MockIterationNode(MockNodeMixin, IterationNode):
variable_pool_copy.add([self._node_id, "item"], item)
# Create a new GraphRuntimeState for this iteration
graph_runtime_state_copy = create_graph_runtime_state(
graph_runtime_state_copy = GraphRuntimeState(
variable_pool=variable_pool_copy,
start_at=self.graph_runtime_state.start_at,
workflow_id=self.workflow_id,
total_tokens=0,
node_run_steps=0,
)
@@ -668,11 +666,6 @@ class MockIterationNode(MockNodeMixin, IterationNode):
from graphon.nodes.iteration.exc import IterationGraphNotFoundError
raise IterationGraphNotFoundError("iteration graph not found")
bind_graph_runtime_state_to_graph(
graph_runtime_state_copy,
iteration_graph,
workflow_id=self.workflow_id,
)
# Create a new GraphEngine for this iteration
graph_engine = GraphEngine(
@@ -701,8 +694,7 @@ class MockLoopNode(MockNodeMixin, LoopNode):
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import InMemoryChannel
from core.workflow.runtime_state import bind_graph_runtime_state_to_graph, create_graph_runtime_state
from graphon.runtime import GraphRuntimeState
# Import our MockNodeFactory instead of DifyNodeFactory
from .test_mock_factory import MockNodeFactory
@@ -716,10 +708,9 @@ class MockLoopNode(MockNodeMixin, LoopNode):
)
# Create a new GraphRuntimeState for this iteration
graph_runtime_state_copy = create_graph_runtime_state(
graph_runtime_state_copy = GraphRuntimeState(
variable_pool=self.graph_runtime_state.variable_pool,
start_at=start_at.timestamp(),
workflow_id=self.workflow_id,
)
# Create a MockNodeFactory with the same mock_config
@@ -734,11 +725,6 @@ class MockLoopNode(MockNodeMixin, LoopNode):
if not loop_graph:
raise ValueError("loop graph not found")
bind_graph_runtime_state_to_graph(
graph_runtime_state_copy,
loop_graph,
workflow_id=self.workflow_id,
)
# Create a new GraphEngine for this iteration
graph_engine = GraphEngine(

View File

@@ -30,11 +30,6 @@ from core.repositories.human_input_repository import (
HumanInputFormRepository,
)
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
from core.workflow.runtime_state import (
bind_graph_runtime_state_to_graph,
create_graph_runtime_state,
snapshot_graph_runtime_state,
)
from core.workflow.system_variables import build_system_variables
from libs.datetime_utils import naive_utc_now
from tests.workflow_test_utils import build_test_graph_init_params
@@ -51,10 +46,7 @@ class InMemoryPauseStore:
self._snapshot: str | None = None
def save(self, runtime_state: GraphRuntimeState) -> None:
self._snapshot = snapshot_graph_runtime_state(
runtime_state,
workflow_id="workflow",
)
self._snapshot = runtime_state.dumps()
def load(self) -> GraphRuntimeState:
assert self._snapshot is not None
@@ -130,11 +122,7 @@ def _build_runtime_state() -> GraphRuntimeState:
user_inputs={},
conversation_variables=[],
)
return create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id="workflow",
)
return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepository) -> Graph:
@@ -212,11 +200,6 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]:
bind_graph_runtime_state_to_graph(
runtime_state,
graph,
workflow_id="workflow",
)
engine = GraphEngine(
workflow_id="workflow",
graph=graph,

View File

@@ -42,7 +42,6 @@ from graphon.variables import (
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
from core.tools.utils.yaml_utils import _load_yaml_file
from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id
from core.workflow.runtime_state import bind_graph_runtime_state_to_graph, create_graph_runtime_state
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
@@ -66,10 +65,9 @@ class _TableTestChildEngineBuilder:
root_node_id: str,
variable_pool: VariablePool | None = None,
) -> GraphEngine:
child_graph_runtime_state = create_graph_runtime_state(
child_graph_runtime_state = GraphRuntimeState(
variable_pool=variable_pool if variable_pool is not None else parent_graph_runtime_state.variable_pool,
start_at=time.perf_counter(),
workflow_id=workflow_id,
execution_context=parent_graph_runtime_state.execution_context,
)
if self._use_mock_factory:
@@ -88,11 +86,6 @@ class _TableTestChildEngineBuilder:
child_graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id)
if not child_graph:
raise ValueError("child graph not found")
bind_graph_runtime_state_to_graph(
child_graph_runtime_state,
child_graph,
workflow_id=workflow_id,
)
child_engine = GraphEngine(
workflow_id=workflow_id,
@@ -268,11 +261,7 @@ class WorkflowRunner:
)
add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=root_node_inputs)
graph_runtime_state = create_graph_runtime_state(
variable_pool=variable_pool,
start_at=time.perf_counter(),
workflow_id=graph_init_params.workflow_id,
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
if use_mock_factory:
node_factory = MockNodeFactory(
@@ -286,11 +275,6 @@ class WorkflowRunner:
node_factory=node_factory,
root_node_id=root_node_id,
)
bind_graph_runtime_state_to_graph(
graph_runtime_state,
graph,
workflow_id=graph_init_params.workflow_id,
)
return graph, graph_runtime_state
@@ -382,11 +366,6 @@ class TableTestRunner:
try:
graph, graph_runtime_state = self._create_graph_runtime_state(test_case)
bind_graph_runtime_state_to_graph(
graph_runtime_state,
graph,
workflow_id="test_workflow",
)
# Create and run the engine with configured worker settings
engine = GraphEngine(

View File

@@ -5,8 +5,6 @@ from graphon.graph_events import (
NodeRunStreamChunkEvent,
)
from core.workflow.runtime_state import bind_graph_runtime_state_to_graph
from .test_table_runner import TableTestRunner
@@ -22,11 +20,6 @@ def test_tool_in_chatflow():
query="1",
use_mock_factory=True,
)
bind_graph_runtime_state_to_graph(
graph_runtime_state,
graph,
workflow_id="test_workflow",
)
# Create and run the engine
engine = GraphEngine(

View File

@@ -1034,7 +1034,7 @@ class TestRegisterService:
)
# Verify rollback operations were called
mock_db_dependencies["db"].session.execute.assert_called()
mock_db_dependencies["db"].session.query.assert_called()
# ==================== Registration Tests ====================
@@ -1599,8 +1599,10 @@ class TestRegisterService:
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = mock_existing_account
# Mock scalar for TenantAccountJoin lookup - no existing member
mock_db_dependencies["db"].session.scalar.return_value = None
# Mock the db.session.query for TenantAccountJoin
mock_db_query = MagicMock()
mock_db_query.filter_by.return_value.first.return_value = None # No existing member
mock_db_dependencies["db"].session.query.return_value = mock_db_query
# Mock TenantService methods
with (
@@ -1775,9 +1777,14 @@ class TestRegisterService:
}
mock_get_invitation_by_token.return_value = invitation_data
# Mock scalar for tenant lookup, execute for account+role lookup
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Mock database queries - complex query mocking
mock_query1 = MagicMock()
mock_query1.where.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1809,8 +1816,10 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock scalar for tenant lookup - not found
mock_db_dependencies["db"].session.scalar.return_value = None
# Mock database queries - no tenant found
mock_query = MagicMock()
mock_query.filter.return_value.first.return_value = None
mock_db_dependencies["db"].session.query.return_value = mock_query
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1833,9 +1842,14 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = None # No account found
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1861,9 +1875,14 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")

View File

@@ -9,12 +9,11 @@ from threading import Event
import pytest
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.runtime import VariablePool
from graphon.runtime import GraphRuntimeState, VariablePool
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.runtime_state import create_graph_runtime_state, snapshot_graph_runtime_state
from models.enums import CreatorUserRole
from models.model import AppMode
from models.workflow import WorkflowRun
@@ -117,20 +116,13 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
call_depth=0,
workflow_execution_id="run-1",
)
runtime_state = create_graph_runtime_state(
variable_pool=VariablePool(),
start_at=0.0,
workflow_id="workflow-1",
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state.register_paused_node("node-1")
runtime_state.outputs = {"result": "value"}
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
return WorkflowResumptionContext(
generate_entity=wrapper,
serialized_graph_runtime_state=snapshot_graph_runtime_state(
runtime_state,
workflow_id="workflow-1",
),
serialized_graph_runtime_state=runtime_state.dumps(),
)

View File

@@ -5,6 +5,7 @@
"prepare": "vp config"
},
"devDependencies": {
"taze": "catalog:",
"vite-plus": "catalog:"
},
"engines": {

2
packages/dify-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist/
node_modules/

View File

@@ -0,0 +1,82 @@
{
"name": "@langgenius/dify-ui",
"private": true,
"version": "0.0.0-private",
"type": "module",
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"exports": {
"./context-menu": {
"types": "./dist/context-menu/index.d.ts",
"import": "./dist/context-menu/index.js",
"default": "./dist/context-menu/index.js"
},
"./dropdown-menu": {
"types": "./dist/dropdown-menu/index.d.ts",
"import": "./dist/dropdown-menu/index.js",
"default": "./dist/dropdown-menu/index.js"
},
"./tailwind-preset": {
"types": "./dist/tailwind-preset.d.ts",
"import": "./dist/tailwind-preset.js",
"default": "./dist/tailwind-preset.js"
},
"./styles.css": "./dist/styles.css",
"./markdown.css": "./dist/markdown.css",
"./themes/light.css": "./dist/themes/light.css",
"./themes/dark.css": "./dist/themes/dark.css",
"./themes/manual-light.css": "./dist/themes/manual-light.css",
"./themes/manual-dark.css": "./dist/themes/manual-dark.css",
"./themes/markdown-light.css": "./dist/themes/markdown-light.css",
"./themes/markdown-dark.css": "./dist/themes/markdown-dark.css",
"./tokens/tailwind-theme-var-define": {
"types": "./dist/tokens/tailwind-theme-var-define.d.ts",
"import": "./dist/tokens/tailwind-theme-var-define.js",
"default": "./dist/tokens/tailwind-theme-var-define.js"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "node ./scripts/build.mjs",
"prepack": "pnpm build",
"test": "vp test",
"test:watch": "vp test --watch",
"type-check": "tsc -p tsconfig.json --noEmit"
},
"peerDependencies": {
"react": "catalog:",
"react-dom": "catalog:"
},
"dependencies": {
"@base-ui/react": "catalog:",
"@dify/iconify-collections": "workspace:*",
"@egoist/tailwindcss-icons": "catalog:",
"@iconify-json/heroicons": "catalog:",
"@iconify-json/ri": "catalog:",
"@remixicon/react": "catalog:",
"@tailwindcss/typography": "catalog:",
"clsx": "catalog:",
"tailwind-merge": "catalog:"
},
"devDependencies": {
"@storybook/react": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"happy-dom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,31 @@
import { cp, mkdir, rm } from 'node:fs/promises'
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const distDir = resolve(packageRoot, 'dist')
await rm(distDir, { recursive: true, force: true })
const tsc = spawnSync('pnpm', ['exec', 'tsc', '-p', 'tsconfig.build.json'], {
cwd: packageRoot,
stdio: 'inherit',
})
if (tsc.status !== 0)
process.exit(tsc.status ?? 1)
await mkdir(distDir, { recursive: true })
await cp(resolve(packageRoot, 'src/styles.css'), resolve(packageRoot, 'dist/styles.css'))
await cp(resolve(packageRoot, 'src/markdown.css'), resolve(packageRoot, 'dist/markdown.css'))
await cp(resolve(packageRoot, 'src/styles'), resolve(packageRoot, 'dist/styles'), {
force: true,
recursive: true,
})
await cp(resolve(packageRoot, 'src/themes'), resolve(packageRoot, 'dist/themes'), {
force: true,
recursive: true,
})

View File

@@ -1,4 +1,10 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Meta, StoryObj } from '@storybook/react'
import {
RiDeleteBinLine,
RiFileCopyLine,
RiPencilLine,
RiShareLine,
} from '@remixicon/react'
import { useState } from 'react'
import {
ContextMenu,
@@ -17,7 +23,7 @@ import {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '.'
} from './index'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
@@ -185,17 +191,17 @@ export const Complex: Story = {
<TriggerArea label="Right-click to inspect all menu capabilities" />
<ContextMenuContent>
<ContextMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Rename
</ContextMenuItem>
<ContextMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Duplicate
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
<RiShareLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Share
</ContextMenuSubTrigger>
<ContextMenuSubContent>
@@ -206,7 +212,7 @@ export const Complex: Story = {
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
Delete
</ContextMenuItem>
</ContextMenuContent>

View File

@@ -1,8 +1,10 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import type { Placement } from '../internal/placement.js'
import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu'
import { RiArrowRightSLine, RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '../internal/cn.js'
import {
menuBackdropClassName,
menuGroupLabelClassName,
@@ -11,9 +13,8 @@ import {
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
} from '../internal/menu-shared.js'
import { parsePlacement } from '../internal/placement.js'
export const ContextMenu = BaseContextMenu.Root
export const ContextMenuTrigger = BaseContextMenu.Trigger
@@ -42,11 +43,11 @@ type ContextMenuPopupRenderProps = Required<Pick<ContextMenuContentProps, 'child
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
withBackdrop?: boolean
className?: string | undefined
popupClassName?: string | undefined
positionerProps?: ContextMenuContentProps['positionerProps'] | undefined
popupProps?: ContextMenuContentProps['popupProps'] | undefined
withBackdrop?: boolean | undefined
}
function renderContextMenuPopup({
@@ -173,6 +174,25 @@ export function ContextMenuCheckboxItem({
)
}
type ContextMenuIndicatorProps = Omit<React.ComponentPropsWithoutRef<'span'>, 'children'> & {
children?: React.ReactNode
}
export function ContextMenuItemIndicator({
className,
children,
...props
}: ContextMenuIndicatorProps) {
return (
<span
aria-hidden
className={cn(menuIndicatorClassName, className)}
{...props}
>
{children ?? <RiCheckLine aria-hidden className="h-4 w-4" />}
</span>
)
}
export function ContextMenuCheckboxItemIndicator({
className,
...props
@@ -182,7 +202,7 @@ export function ContextMenuCheckboxItemIndicator({
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
<RiCheckLine aria-hidden className="h-4 w-4" />
</BaseContextMenu.CheckboxItemIndicator>
)
}
@@ -196,7 +216,7 @@ export function ContextMenuRadioItemIndicator({
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
<RiCheckLine aria-hidden className="h-4 w-4" />
</BaseContextMenu.RadioItemIndicator>
)
}
@@ -217,20 +237,20 @@ export function ContextMenuSubTrigger({
{...props}
>
{children}
<span aria-hidden className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" />
<RiArrowRightSLine aria-hidden className="ml-auto size-4 shrink-0 text-text-tertiary" />
</BaseContextMenu.SubmenuTrigger>
)
}
type ContextMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: ContextMenuContentProps['positionerProps']
popupProps?: ContextMenuContentProps['popupProps']
placement?: Placement | undefined
sideOffset?: number | undefined
alignOffset?: number | undefined
className?: string | undefined
popupClassName?: string | undefined
positionerProps?: ContextMenuContentProps['positionerProps'] | undefined
popupProps?: ContextMenuContentProps['popupProps'] | undefined
}
export function ContextMenuSubContent({
@@ -278,3 +298,5 @@ export function ContextMenuSeparator({
/>
)
}
export type { Placement }

View File

@@ -1,7 +1,6 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Link from '@/next/link'
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,20 +13,20 @@ import {
DropdownMenuTrigger,
} from '../index'
vi.mock('@/next/link', () => ({
default: ({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) => (
function MockLink({
href,
children,
...props
}: {
href: string
children?: ReactNode
} & Omit<ComponentPropsWithoutRef<'a'>, 'href'>) {
return (
<a href={href} {...props}>
{children}
</a>
),
}))
)
}
describe('dropdown-menu wrapper', () => {
describe('DropdownMenuContent', () => {
@@ -301,7 +300,7 @@ describe('dropdown-menu wrapper', () => {
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLinkItem
render={<Link href="/account" />}
render={<MockLink href="/account" />}
aria-label="account link"
>
Account settings

View File

@@ -1,4 +1,15 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { Meta, StoryObj } from '@storybook/react'
import {
RiArchiveLine,
RiChat1Line,
RiDeleteBinLine,
RiFileCopyLine,
RiLink,
RiLockLine,
RiMailLine,
RiPencilLine,
RiShareLine,
} from '@remixicon/react'
import { useState } from 'react'
import {
DropdownMenu,
@@ -17,7 +28,7 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '.'
} from './index'
const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => (
<DropdownMenuTrigger
@@ -214,20 +225,20 @@ export const WithIcons: Story = {
<TriggerButton />
<DropdownMenuContent>
<DropdownMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Edit
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
<RiArchiveLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
@@ -262,35 +273,35 @@ const ComplexDemo = () => {
<DropdownMenuGroup>
<DropdownMenuGroupLabel>Edit</DropdownMenuGroupLabel>
<DropdownMenuItem>
<span aria-hidden className="i-ri-pencil-line size-4 shrink-0 text-text-tertiary" />
<RiPencilLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Rename
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-file-copy-line size-4 shrink-0 text-text-tertiary" />
<RiFileCopyLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled>
<span aria-hidden className="i-ri-lock-line size-4 shrink-0 text-text-tertiary" />
<RiLockLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Move to Workspace
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span aria-hidden className="i-ri-share-line size-4 shrink-0 text-text-tertiary" />
<RiShareLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Share
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>
<span aria-hidden className="i-ri-mail-line size-4 shrink-0 text-text-tertiary" />
<RiMailLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Email
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-chat-1-line size-4 shrink-0 text-text-tertiary" />
<RiChat1Line aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Slack
</DropdownMenuItem>
<DropdownMenuItem>
<span aria-hidden className="i-ri-link size-4 shrink-0 text-text-tertiary" />
<RiLink aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Copy Link
</DropdownMenuItem>
</DropdownMenuSubContent>
@@ -315,13 +326,13 @@ const ComplexDemo = () => {
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked={showArchived} onCheckedChange={setShowArchived}>
<span aria-hidden className="i-ri-archive-line size-4 shrink-0 text-text-tertiary" />
<RiArchiveLine aria-hidden className="size-4 shrink-0 text-text-tertiary" />
Show Archived
<DropdownMenuCheckboxItemIndicator />
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<RiDeleteBinLine aria-hidden className="size-4 shrink-0" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,8 +1,10 @@
'use client'
import type { Placement } from '@/app/components/base/ui/placement'
import type { Placement } from '../internal/placement.js'
import { Menu } from '@base-ui/react/menu'
import { RiArrowRightSLine, RiCheckLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '../internal/cn.js'
import {
menuGroupLabelClassName,
menuIndicatorClassName,
@@ -10,9 +12,8 @@ import {
menuPopupBaseClassName,
menuRowClassName,
menuSeparatorClassName,
} from '@/app/components/base/ui/menu-shared'
import { parsePlacement } from '@/app/components/base/ui/placement'
import { cn } from '@/utils/classnames'
} from '../internal/menu-shared.js'
import { parsePlacement } from '../internal/placement.js'
export const DropdownMenu = Menu.Root
export const DropdownMenuTrigger = Menu.Trigger
@@ -41,7 +42,7 @@ export function DropdownMenuRadioItemIndicator({
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
<RiCheckLine aria-hidden className="h-4 w-4" />
</Menu.RadioItemIndicator>
)
}
@@ -67,7 +68,7 @@ export function DropdownMenuCheckboxItemIndicator({
className={cn(menuIndicatorClassName, className)}
{...props}
>
<span aria-hidden className="i-ri-check-line h-4 w-4" />
<RiCheckLine aria-hidden className="h-4 w-4" />
</Menu.CheckboxItemIndicator>
)
}
@@ -105,10 +106,10 @@ type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'chi
placement: Placement
sideOffset: number
alignOffset: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
className?: string | undefined
popupClassName?: string | undefined
positionerProps?: DropdownMenuContentProps['positionerProps'] | undefined
popupProps?: DropdownMenuContentProps['popupProps'] | undefined
}
function renderDropdownMenuPopup({
@@ -186,20 +187,20 @@ export function DropdownMenuSubTrigger({
{...props}
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-4 shrink-0 text-text-tertiary" />
<RiArrowRightSLine aria-hidden className="ml-auto size-4 shrink-0 text-text-tertiary" />
</Menu.SubmenuTrigger>
)
}
type DropdownMenuSubContentProps = {
children: React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: DropdownMenuContentProps['positionerProps']
popupProps?: DropdownMenuContentProps['popupProps']
placement?: Placement | undefined
sideOffset?: number | undefined
alignOffset?: number | undefined
className?: string | undefined
popupClassName?: string | undefined
positionerProps?: DropdownMenuContentProps['positionerProps'] | undefined
popupProps?: DropdownMenuContentProps['popupProps'] | undefined
}
export function DropdownMenuSubContent({
@@ -271,3 +272,5 @@ export function DropdownMenuSeparator({
/>
)
}
export type { Placement }

View File

@@ -0,0 +1,7 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,25 @@
type Side = 'top' | 'bottom' | 'left' | 'right'
type Align = 'start' | 'center' | 'end'
export type Placement
= 'top'
| 'top-start'
| 'top-end'
| 'right'
| 'right-start'
| 'right-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
const [side, align] = placement.split('-') as [Side, Align | undefined]
return {
side,
align: align ?? 'center',
}
}

View File

@@ -0,0 +1,2 @@
@import './themes/markdown-light.css';
@import './themes/markdown-dark.css';

View File

@@ -0,0 +1,7 @@
@import './themes/light.css' layer(base);
@import './themes/dark.css' layer(base);
@import './themes/manual-light.css' layer(base);
@import './themes/manual-dark.css' layer(base);
@import './styles/tokens.css';
@source './**/*.{js,mjs}';

View File

@@ -0,0 +1,713 @@
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility system-kbd {
/* font define start */
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-2xs-regular-uppercase {
font-size: 10px;
font-weight: 400;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-regular {
font-size: 10px;
font-weight: 400;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-medium {
font-size: 10px;
font-weight: 500;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-medium-uppercase {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-semibold-uppercase {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility system-xs-regular-uppercase {
font-size: 12px;
font-weight: 400;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-xs-medium {
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-xs-medium-uppercase {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-xs-semibold {
font-size: 12px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility system-xs-semibold-uppercase {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility system-sm-medium {
font-size: 13px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-sm-medium-uppercase {
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-sm-semibold {
font-size: 13px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility system-sm-semibold-uppercase {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility system-md-medium {
font-size: 14px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility system-md-semibold {
font-size: 14px;
font-weight: 600;
line-height: 20px;
/* border radius end */
}
@utility system-md-semibold-uppercase {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
line-height: 20px;
/* border radius end */
}
@utility system-xl-regular {
font-size: 16px;
font-weight: 400;
line-height: 24px;
/* border radius end */
}
@utility system-xl-medium {
font-size: 16px;
font-weight: 500;
line-height: 24px;
/* border radius end */
}
@utility system-xl-semibold {
font-size: 16px;
font-weight: 600;
line-height: 24px;
/* border radius end */
}
@utility code-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-xs-semibold {
font-size: 12px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility code-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-sm-semibold {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility code-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-md-semibold {
font-size: 14px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility body-xs-light {
font-size: 12px;
font-weight: 300;
line-height: 16px;
/* border radius end */
}
@utility body-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility body-xs-medium {
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility body-sm-light {
font-size: 13px;
font-weight: 300;
line-height: 16px;
/* border radius end */
}
@utility body-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility body-sm-medium {
font-size: 13px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility body-md-light {
font-size: 14px;
font-weight: 300;
line-height: 20px;
/* border radius end */
}
@utility body-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility body-md-medium {
font-size: 14px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility body-lg-light {
font-size: 15px;
font-weight: 300;
line-height: 20px;
/* border radius end */
}
@utility body-lg-regular {
font-size: 15px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility body-lg-medium {
font-size: 15px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility body-xl-regular {
font-size: 16px;
font-weight: 400;
line-height: 24px;
/* border radius end */
}
@utility body-xl-medium {
font-size: 16px;
font-weight: 500;
line-height: 24px;
/* border radius end */
}
@utility body-xl-light {
font-size: 16px;
font-weight: 300;
line-height: 24px;
/* border radius end */
}
@utility body-2xl-light {
font-size: 18px;
font-weight: 300;
line-height: 1.5;
/* border radius end */
}
@utility body-2xl-regular {
font-size: 18px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility body-2xl-medium {
font-size: 18px;
font-weight: 500;
line-height: 1.5;
/* border radius end */
}
@utility title-xs-semi-bold {
font-size: 12px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility title-xs-bold {
font-size: 12px;
font-weight: 700;
line-height: 16px;
/* border radius end */
}
@utility title-sm-semi-bold {
font-size: 13px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility title-sm-bold {
font-size: 13px;
font-weight: 700;
line-height: 16px;
/* border radius end */
}
@utility title-md-semi-bold {
font-size: 14px;
font-weight: 600;
line-height: 20px;
/* border radius end */
}
@utility title-md-bold {
font-size: 14px;
font-weight: 700;
line-height: 20px;
/* border radius end */
}
@utility title-lg-semi-bold {
font-size: 15px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-lg-bold {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-xl-semi-bold {
font-size: 16px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-xl-bold {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-2xl-semi-bold {
font-size: 18px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-2xl-bold {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-3xl-semi-bold {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-3xl-bold {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-4xl-semi-bold {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-4xl-bold {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-5xl-semi-bold {
font-size: 30px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-5xl-bold {
font-size: 30px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-6xl-semi-bold {
font-size: 36px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-6xl-bold {
font-size: 36px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-7xl-semi-bold {
font-size: 48px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-7xl-bold {
font-size: 48px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-8xl-semi-bold {
font-size: 60px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-8xl-bold {
font-size: 60px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility radius-2xs {
/* font define end */
/* border radius start */
border-radius: 2px;
/* border radius end */
}
@utility radius-xs {
border-radius: 4px;
/* border radius end */
}
@utility radius-sm {
border-radius: 6px;
/* border radius end */
}
@utility radius-md {
border-radius: 8px;
/* border radius end */
}
@utility radius-lg {
border-radius: 10px;
/* border radius end */
}
@utility radius-xl {
border-radius: 12px;
/* border radius end */
}
@utility radius-2xl {
border-radius: 16px;
/* border radius end */
}
@utility radius-3xl {
border-radius: 20px;
/* border radius end */
}
@utility radius-4xl {
border-radius: 24px;
/* border radius end */
}
@utility radius-5xl {
border-radius: 24px;
/* border radius end */
}
@utility radius-6xl {
border-radius: 28px;
/* border radius end */
}
@utility radius-7xl {
border-radius: 32px;
/* border radius end */
}
@utility radius-8xl {
border-radius: 40px;
/* border radius end */
}
@utility radius-9xl {
border-radius: 48px;
/* border radius end */
}
@utility radius-full {
border-radius: 64px;
/* border radius end */
}
@utility no-scrollbar {
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
@utility no-spinner {
/* Hide arrows from number input */
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}

View File

@@ -1,8 +1,10 @@
import { icons as customPublicIcons } from '@dify/iconify-collections/custom-public'
import { icons as customVenderIcons } from '@dify/iconify-collections/custom-vender'
import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
import { icons as heroicons } from '@iconify-json/heroicons'
import { icons as remixIcons } from '@iconify-json/ri'
import { iconsPlugin } from '@egoist/tailwindcss-icons'
import tailwindTypography from '@tailwindcss/typography'
import tailwindThemeVarDefine from './themes/tailwind-theme-var-define'
import tailwindThemeVarDefine from './tokens/tailwind-theme-var-define.js'
import typography from './typography.js'
const config = {
@@ -151,7 +153,8 @@ const config = {
tailwindTypography,
iconsPlugin({
collections: {
...getIconCollections(['heroicons', 'ri']),
heroicons,
ri: remixIcons,
'custom-public': customPublicIcons,
'custom-vender': customVenderIcons,
},

3
packages/dify-ui/src/typography.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare const typography: (helpers: { theme: (path: string) => unknown }) => Record<string, unknown>
export default typography

View File

@@ -0,0 +1,8 @@
import difyUiTailwindPreset from './src/tailwind-preset'
const config = {
content: [],
...difyUiTailwindPreset,
}
export default config

View File

@@ -0,0 +1,21 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js"
],
"exclude": [
"src/**/*.stories.tsx",
"src/**/__tests__/**"
]
}

View File

@@ -0,0 +1,38 @@
{
"compilerOptions": {
"target": "es2022",
"jsx": "react-jsx",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"module": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noEmit": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": [
"node",
"vitest/globals",
"@testing-library/jest-dom"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
"scripts/**/*.mjs",
"vite.config.ts",
"vitest.setup.ts"
]
}

View File

@@ -0,0 +1,11 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite-plus'
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
})

View File

@@ -0,0 +1,39 @@
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe() {
return undefined
}
unobserve() {
return undefined
}
disconnect() {
return undefined
}
}
}
if (typeof globalThis.IntersectionObserver === 'undefined') {
globalThis.IntersectionObserver = class {
readonly root: Element | Document | null = null
readonly rootMargin = ''
readonly scrollMargin = ''
readonly thresholds: ReadonlyArray<number> = []
constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {}
observe(_target: Element) {}
unobserve(_target: Element) {}
disconnect() {}
takeRecords(): IntersectionObserverEntry[] {
return []
}
}
}
afterEach(() => {
cleanup()
})

639
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,6 @@ overrides:
is-generator-function: npm:@nolyfill/is-generator-function@^1.0.44
is-typed-array: npm:@nolyfill/is-typed-array@^1.0.44
isarray: npm:@nolyfill/isarray@^1.0.44
lodash@>=4.0.0 <= 4.17.23: 4.18.0
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
object.assign: npm:@nolyfill/object.assign@^1.0.44
object.entries: npm:@nolyfill/object.entries@^1.0.44
@@ -132,7 +131,6 @@ catalog:
"@tanstack/react-form-devtools": 0.2.20
"@tanstack/react-query": 5.96.1
"@tanstack/react-query-devtools": 5.96.1
"@tanstack/react-virtual": 3.13.23
"@testing-library/dom": 10.4.1
"@testing-library/jest-dom": 6.9.1
"@testing-library/react": 16.3.2
@@ -148,6 +146,8 @@ catalog:
"@types/qs": 6.15.0
"@types/react": 19.2.14
"@types/react-dom": 19.2.3
"@types/react-syntax-highlighter": 15.5.13
"@types/react-window": 1.8.8
"@types/sortablejs": 1.15.9
"@typescript-eslint/eslint-plugin": 8.58.0
"@typescript-eslint/parser": 8.58.0
@@ -162,7 +162,7 @@ catalog:
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.5.1
code-inspector-plugin: 1.5.0
copy-to-clipboard: 3.3.3
cron-parser: 5.5.0
dayjs: 1.11.20
@@ -187,7 +187,6 @@ catalog:
fast-deep-equal: 3.1.3
foxact: 0.3.0
happy-dom: 20.8.9
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.10
html-entities: 2.6.0
html-to-image: 1.11.13
@@ -228,13 +227,14 @@ catalog:
react-pdf-highlighter: 8.0.0-rc.0
react-server-dom-webpack: 19.2.4
react-sortablejs: 6.1.4
react-syntax-highlighter: 15.6.6
react-textarea-autosize: 8.5.9
react-window: 1.8.11
reactflow: 11.11.4
remark-breaks: 4.0.0
remark-directive: 4.0.0
scheduler: 0.27.0
sharp: 0.34.5
shiki: 4.0.2
sortablejs: 1.15.7
std-semver: 1.0.8
storybook: 10.3.4
@@ -242,6 +242,7 @@ catalog:
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
taze: 19.11.0
tldts: 7.0.27
tsdown: 0.21.7
tsx: 4.21.0

10
taze.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'taze'
export default defineConfig({
exclude: [
// We are going to replace these
'react-syntax-highlighter',
'react-window',
'@types/react-window',
],
})

View File

@@ -1,7 +1,10 @@
import type { StorybookConfig } from '@storybook/nextjs-vite'
const config: StorybookConfig = {
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
stories: [
'../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)',
'../../packages/dify-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
// Not working with Storybook Vite framework
// '@storybook/addon-onboarding',

View File

@@ -1,36 +0,0 @@
import { vi } from 'vitest'
const mockVirtualizer = ({
count,
estimateSize,
}: {
count: number
estimateSize?: (index: number) => number
}) => {
const getSize = (index: number) => estimateSize?.(index) ?? 0
return {
getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0),
getVirtualItems: () => {
let start = 0
return Array.from({ length: count }).map((_, index) => {
const size = getSize(index)
const virtualItem = {
end: start + size,
index,
key: index,
size,
start,
}
start += size
return virtualItem
})
},
measureElement: vi.fn(),
scrollToIndex: vi.fn(),
}
}
export { mockVirtualizer as useVirtualizer }

View File

@@ -5,10 +5,6 @@ import { Theme } from '@/types/app'
import CodeBlock from '../code-block'
const { mockHighlightCode } = vi.hoisted(() => ({
mockHighlightCode: vi.fn(),
}))
type UseThemeReturn = {
theme: Theme
}
@@ -74,10 +70,6 @@ vi.mock('@/hooks/use-theme', () => ({
default: () => mockUseTheme(),
}))
vi.mock('../shiki-highlight', () => ({
highlightCode: mockHighlightCode,
}))
vi.mock('echarts', () => ({
getInstanceByDom: mockEcharts.getInstanceByDom,
}))
@@ -138,11 +130,6 @@ describe('CodeBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTheme.mockReturnValue({ theme: Theme.light })
mockHighlightCode.mockImplementation(async ({ code, language }) => (
<pre className="shiki">
<code className={`language-${language}`}>{code}</code>
</pre>
))
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
clientWidthSpy = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(900)
@@ -211,13 +198,11 @@ describe('CodeBlock', () => {
expect(container.querySelector('code')?.textContent).toBe('plain text')
})
it('should render syntax-highlighted output when language is standard', async () => {
it('should render syntax-highlighted output when language is standard', () => {
render(<CodeBlock className="language-javascript">const x = 1;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
})
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const x = 1;')
})
it('should format unknown language labels with capitalized fallback when language is not in map', () => {
@@ -257,26 +242,13 @@ describe('CodeBlock', () => {
expect(screen.queryByText(/Error rendering SVG/i)).not.toBeInTheDocument()
})
it('should render syntax-highlighted output when language is standard and app theme is dark', async () => {
it('should render syntax-highlighted output when language is standard and app theme is dark', () => {
mockUseTheme.mockReturnValue({ theme: Theme.dark })
render(<CodeBlock className="language-javascript">const y = 2;</CodeBlock>)
expect(screen.getByText('JavaScript')).toBeInTheDocument()
await waitFor(() => {
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
})
})
it('should fall back to plain code block when shiki highlighting fails', async () => {
mockHighlightCode.mockRejectedValueOnce(new Error('highlight failed'))
render(<CodeBlock className="language-javascript">const z = 3;</CodeBlock>)
await waitFor(() => {
expect(screen.getByText('const z = 3;')).toBeInTheDocument()
})
expect(document.querySelector('code.language-javascript')).toBeNull()
expect(document.querySelector('code.language-javascript')?.textContent).toContain('const y = 2;')
})
})

View File

@@ -1,7 +1,10 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import ReactEcharts from 'echarts-for-react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import SyntaxHighlighter from 'react-syntax-highlighter'
import {
atelierHeathDark,
atelierHeathLight,
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
import ActionButton from '@/app/components/base/action-button'
import CopyIcon from '@/app/components/base/copy-icon'
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
@@ -11,10 +14,10 @@ import useTheme from '@/hooks/use-theme'
import dynamic from '@/next/dynamic'
import { Theme } from '@/types/app'
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
import { highlightCode } from './shiki-highlight'
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
const capitalizationLanguageNameMap: Record<string, string> = {
sql: 'SQL',
javascript: 'JavaScript',
@@ -61,61 +64,6 @@ const getCorrectCapitalizationLanguageName = (language: string) => {
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
// or use the non-minified dev environment for full errors and additional helpful warnings.
const ShikiCodeBlock = memo(({ code, language, theme, initial }: { code: string, language: string, theme: BundledTheme, initial?: JSX.Element }) => {
const [nodes, setNodes] = useState(initial)
useLayoutEffect(() => {
let cancelled = false
void highlightCode({
code,
language: language as BundledLanguage,
theme,
}).then((result) => {
if (!cancelled)
setNodes(result)
}).catch((error) => {
console.error('Shiki highlighting failed:', error)
if (!cancelled)
setNodes(undefined)
})
return () => {
cancelled = true
}
}, [code, language, theme])
if (!nodes) {
return (
<pre style={{
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
margin: 0,
overflow: 'auto',
}}
>
<code>{code}</code>
</pre>
)
}
return (
<div
style={{
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
overflow: 'auto',
}}
className="shiki-line-numbers [&_pre]:m-0! [&_pre]:rounded-t-none! [&_pre]:rounded-b-[10px]! [&_pre]:bg-components-input-bg-normal! [&_pre]:py-2!"
>
{nodes}
</div>
)
})
ShikiCodeBlock.displayName = 'ShikiCodeBlock'
// Define ECharts event parameter types
type EChartsEventParams = {
type: string
@@ -468,11 +416,20 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
)
default:
return (
<ShikiCodeBlock
code={content}
language={match?.[1] || 'text'}
theme={isDarkMode ? 'github-dark' : 'github-light'}
/>
<SyntaxHighlighter
{...props}
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
customStyle={{
paddingLeft: 12,
borderBottomLeftRadius: '10px',
borderBottomRightRadius: '10px',
backgroundColor: 'var(--color-components-input-bg-normal)',
}}
language={match?.[1]}
showLineNumbers
>
{content}
</SyntaxHighlighter>
)
}
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
@@ -483,7 +440,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
return (
<div className="relative">
<div className="flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3">
<div className="system-xs-semibold-uppercase text-text-secondary">{languageShowName}</div>
<div className="text-text-secondary system-xs-semibold-uppercase">{languageShowName}</div>
<div className="flex items-center gap-1">
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
<ActionButton>

View File

@@ -1,29 +0,0 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { Fragment } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { codeToHast } from 'shiki/bundle/web'
type HighlightCodeOptions = {
code: string
language: BundledLanguage
theme: BundledTheme
}
export const highlightCode = async ({
code,
language,
theme,
}: HighlightCodeOptions): Promise<JSX.Element> => {
const hast = await codeToHast(code, {
lang: language,
theme,
})
return toJsxRuntime(hast, {
Fragment,
jsx,
jsxs,
}) as JSX.Element
}

View File

@@ -9,8 +9,6 @@ import { useModalContextSelector } from '@/context/modal-context'
import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import'
import NotionPageSelector from '../base'
vi.mock('@tanstack/react-virtual')
vi.mock('@/service/knowledge/use-import', () => ({
usePreImportNotionPages: vi.fn(),
useInvalidPreImportNotionPages: vi.fn(),
@@ -185,7 +183,7 @@ describe('NotionPageSelector Base', () => {
const user = userEvent.setup()
render(<NotionPageSelector credentialList={mockCredentialList} onSelect={vi.fn()} />)
await user.click(screen.getByRole('button', { name: 'common.dataSource.notion.selector.configure' }))
await user.click(screen.getByRole('button', { name: 'Configure Notion' }))
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.DATA_SOURCE })
})

View File

@@ -2,7 +2,6 @@ import type { DataSourceCredential } from '../../header/account-setting/data-sou
import type { NotionCredential } from './credential-selector'
import type { DataSourceNotionPageMap, DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContextSelector } from '@/context/modal-context'
import { useInvalidPreImportNotionPages, usePreImportNotionPages } from '@/service/knowledge/use-import'
@@ -34,7 +33,6 @@ const NotionPageSelector = ({
credentialList,
onSelectCredential,
}: NotionPageSelectorProps) => {
const { t } = useTranslation()
const [searchValue, setSearchValue] = useState('')
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
@@ -50,34 +48,27 @@ const NotionPageSelector = ({
}
})
}, [credentialList])
const [selectedCredentialId, setSelectedCredentialId] = useState(() => notionCredentials[0]?.credentialId ?? '')
const currentCredential = useMemo(() => {
return notionCredentials.find(item => item.credentialId === selectedCredentialId) ?? notionCredentials[0] ?? null
}, [notionCredentials, selectedCredentialId])
const currentCredentialId = currentCredential?.credentialId ?? ''
const [currentCredential, setCurrentCredential] = useState(notionCredentials[0])
useEffect(() => {
onSelectCredential?.(currentCredentialId)
}, [currentCredentialId, onSelectCredential])
useEffect(() => {
if (!notionCredentials.length) {
onSelect([])
return
const credential = notionCredentials.find(item => item.credentialId === currentCredential?.credentialId)
if (!credential) {
const firstCredential = notionCredentials[0]
invalidPreImportNotionPages({ datasetId, credentialId: firstCredential.credentialId })
setCurrentCredential(notionCredentials[0])
onSelect([]) // Clear selected pages when changing credential
onSelectCredential?.(firstCredential.credentialId)
}
if (!selectedCredentialId || selectedCredentialId === currentCredentialId)
return
invalidPreImportNotionPages({ datasetId, credentialId: currentCredentialId })
onSelect([])
}, [currentCredentialId, datasetId, invalidPreImportNotionPages, notionCredentials.length, onSelect, selectedCredentialId])
else {
onSelectCredential?.(credential?.credentialId || '')
}
}, [notionCredentials])
const {
data: notionsPages,
isFetching: isFetchingNotionPages,
isError: isFetchingNotionPagesError,
} = usePreImportNotionPages({ datasetId, credentialId: currentCredentialId })
} = usePreImportNotionPages({ datasetId, credentialId: currentCredential.credentialId || '' })
const pagesMapAndSelectedPagesId: [DataSourceNotionPageMap, Set<string>, Set<string>] = useMemo(() => {
const selectedPagesId = new Set<string>()
@@ -103,24 +94,28 @@ const NotionPageSelector = ({
const defaultSelectedPagesId = useMemo(() => {
return [...Array.from(pagesMapAndSelectedPagesId[1]), ...(value || [])]
}, [pagesMapAndSelectedPagesId, value])
const selectedPagesId = useMemo(() => new Set(defaultSelectedPagesId), [defaultSelectedPagesId])
const [selectedPagesId, setSelectedPagesId] = useState<Set<string>>(() => new Set(defaultSelectedPagesId))
useEffect(() => {
setSelectedPagesId(new Set(defaultSelectedPagesId))
}, [defaultSelectedPagesId])
const handleSearchValueChange = useCallback((value: string) => {
setSearchValue(value)
}, [])
const handleSelectCredential = useCallback((credentialId: string) => {
if (credentialId === currentCredentialId)
return
invalidPreImportNotionPages({ datasetId, credentialId })
setSelectedCredentialId(credentialId)
const credential = notionCredentials.find(item => item.credentialId === credentialId)!
invalidPreImportNotionPages({ datasetId, credentialId: credential.credentialId })
setCurrentCredential(credential)
onSelect([]) // Clear selected pages when changing credential
}, [currentCredentialId, datasetId, invalidPreImportNotionPages, onSelect])
onSelectCredential?.(credential.credentialId)
}, [datasetId, invalidPreImportNotionPages, notionCredentials, onSelect, onSelectCredential])
const handleSelectPages = useCallback((newSelectedPagesId: Set<string>) => {
const selectedPages = Array.from(newSelectedPagesId).map(pageId => pagesMapAndSelectedPagesId[0][pageId])
setSelectedPagesId(new Set(Array.from(newSelectedPagesId)))
onSelect(selectedPages)
}, [pagesMapAndSelectedPagesId, onSelect])
@@ -145,16 +140,16 @@ const NotionPageSelector = ({
<div className="flex flex-col gap-y-2" data-testid="notion-page-selector-base">
<Header
onClickConfiguration={handleConfigureNotion}
title={t('dataSource.notion.selector.headerTitle', { ns: 'common' })}
buttonText={t('dataSource.notion.selector.configure', { ns: 'common' })}
docTitle={t('dataSource.notion.selector.docs', { ns: 'common' })}
title="Choose notion pages"
buttonText="Configure Notion"
docTitle="Notion docs"
docLink="https://www.notion.so/docs"
/>
<div className="rounded-xl border border-components-panel-border bg-background-default-subtle">
<div className="flex h-12 items-center gap-x-2 rounded-t-xl border-b border-b-divider-regular bg-components-panel-bg p-2">
<div className="flex grow items-center gap-x-1">
<WorkspaceSelector
value={currentCredentialId}
value={currentCredential.credentialId}
items={notionCredentials}
onSelect={handleSelectCredential}
/>
@@ -173,7 +168,6 @@ const NotionPageSelector = ({
)
: (
<PageSelector
key={currentCredentialId || 'default'}
value={selectedPagesId}
disabledValue={pagesMapAndSelectedPagesId[2]}
searchValue={searchValue}

View File

@@ -3,8 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import PageSelector from '../index'
vi.mock('@tanstack/react-virtual')
const buildPage = (overrides: Partial<DataSourceNotionPage>): DataSourceNotionPage => ({
page_id: 'page-id',
page_name: 'Page name',

View File

@@ -1,7 +1,11 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePageSelectorModel } from './use-page-selector-model'
import VirtualPageList from './virtual-page-list'
import { areEqual, FixedSizeList as List } from 'react-window'
import { cn } from '@/utils/classnames'
import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon'
type PageSelectorProps = {
value: Set<string>
@@ -13,7 +17,173 @@ type PageSelectorProps = {
canPreview?: boolean
previewPageId?: string
onPreview?: (selectedPageId: string) => void
isMultipleChoice?: boolean
}
type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
depth: number
} & DataSourceNotionPage
const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (!parentId || !pageId)
return
if (parentId !== 'root' && pagesMap[parentId]) {
if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
listTreeMap[parentId] = {
...pagesMap[parentId],
children,
descendants,
depth: 0,
ancestors: [],
}
}
else {
listTreeMap[parentId].children.add(pageId)
listTreeMap[parentId].descendants.add(pageId)
listTreeMap[parentId].descendants.add(leafItem.page_id)
}
leafItem.depth++
leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
if (listTreeMap[parentId].parent_id !== 'root')
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
}
}
const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
handleCheck: (index: number) => void
canPreview?: boolean
handlePreview: (index: number) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
}>) => {
const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors
const breadCrumbs = ancestors.length ? [...ancestors, current.page_name] : [current.page_name]
const disabled = disabledCheckedIds.has(current.page_id)
const renderArrow = () => {
if (hasChild) {
return (
<div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)}
data-testid={`notion-page-toggle-${current.page_id}`}
>
{
current.expand
? <div className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
: <div className="i-ri-arrow-right-s-line h-4 w-4 text-text-tertiary" />
}
</div>
)
}
if (current.parent_id === 'root' || !pagesMap[current.parent_id]) {
return (
<div></div>
)
}
return (
<div className="mr-1 h-5 w-5 shrink-0" style={{ marginLeft: current.depth * 8 }} />
)
}
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
data-testid={`notion-page-row-${current.page_id}`}
>
<Checkbox
className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
id={`notion-page-checkbox-${current.page_id}`}
/>
{!searchValue && renderArrow()}
<NotionIcon
className="mr-1 shrink-0"
type="page"
src={current.page_icon}
/>
<div
className="grow truncate text-[13px] font-medium leading-4 text-text-secondary"
title={current.page_name}
data-testid={`notion-page-name-${current.page_id}`}
>
{current.page_name}
</div>
{
canPreview && (
<div
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => handlePreview(index)}
data-testid={`notion-page-preview-${current.page_id}`}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>
)
}
{
searchValue && (
<div
className="ml-1 max-w-[120px] shrink-0 truncate text-xs text-text-quaternary"
title={breadCrumbs.join(' / ')}
>
{breadCrumbs.join(' / ')}
</div>
)
}
</div>
)
}
const Item = memo(ItemComponent, areEqual)
const PageSelector = ({
value,
@@ -27,25 +197,108 @@ const PageSelector = ({
onPreview,
}: PageSelectorProps) => {
const { t } = useTranslation()
const {
currentPreviewPageId,
effectiveSearchValue,
rows,
handlePreview,
handleSelect,
handleToggle,
} = usePageSelectorModel({
checkedIds: value,
list,
onPreview,
onSelect,
pagesMap,
previewPageId,
searchValue,
selectionMode: 'multiple',
})
const [dataList, setDataList] = useState<NotionPageItem[]>([])
const [localPreviewPageId, setLocalPreviewPageId] = useState('')
if (!rows.length) {
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [list])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id
if (!prev[pageId])
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
return prev
}, {})
}, [list, pagesMap])
const handleToggle = (index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
}
else {
current.expand = true
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...pagesMap[item],
expand: false,
depth: listMapWithChildrenAndDescendants[item].depth,
})),
...dataList.slice(index + 1),
]
}
setDataList(newDataList)
}
const copyValue = new Set(value)
const handleCheck = (index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
copyValue.delete(pageId)
}
else {
if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
copyValue.add(pageId)
}
onSelect(new Set(copyValue))
}
const handlePreview = (index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
setLocalPreviewPageId(pageId)
if (onPreview)
onPreview(pageId)
}
if (!currentDataList.length) {
return (
<div className="flex h-[296px] items-center justify-center text-[13px] text-text-tertiary">
{t('dataSource.notion.selector.noSearchResult', { ns: 'common' })}
@@ -54,18 +307,29 @@ const PageSelector = ({
}
return (
<VirtualPageList
checkedIds={value}
disabledValue={disabledValue}
onPreview={handlePreview}
onSelect={handleSelect}
onToggle={handleToggle}
previewPageId={currentPreviewPageId}
rows={rows}
searchValue={effectiveSearchValue}
selectionMode="multiple"
showPreview={canPreview}
/>
<List
className="py-2"
height={296}
itemCount={currentDataList.length}
itemSize={28}
width="100%"
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds: value,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
}}
>
{Item}
</List>
)
}

View File

@@ -1,116 +0,0 @@
import type { CSSProperties } from 'react'
import type { NotionPageRow as NotionPageRowData, NotionPageSelectionMode } from './types'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Checkbox from '@/app/components/base/checkbox'
import NotionIcon from '@/app/components/base/notion-icon'
import Radio from '@/app/components/base/radio/ui'
import { cn } from '@/utils/classnames'
type NotionPageRowProps = {
checked: boolean
disabled: boolean
isPreviewed: boolean
onPreview: (pageId: string) => void
onSelect: (pageId: string) => void
onToggle: (pageId: string) => void
row: NotionPageRowData
searchValue: string
selectionMode: NotionPageSelectionMode
showPreview: boolean
style: CSSProperties
}
const NotionPageRow = ({
checked,
disabled,
isPreviewed,
onPreview,
onSelect,
onToggle,
row,
searchValue,
selectionMode,
showPreview,
style,
}: NotionPageRowProps) => {
const { t } = useTranslation()
const pageId = row.page.page_id
const breadcrumbs = row.ancestors.length ? [...row.ancestors, row.page.page_name] : [row.page.page_name]
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pr-[2px] pl-2 hover:bg-state-base-hover', isPreviewed && 'bg-state-base-hover')}
style={style}
data-testid={`notion-page-row-${pageId}`}
>
{selectionMode === 'multiple'
? (
<Checkbox
className="mr-2 shrink-0"
checked={checked}
disabled={disabled}
onCheck={() => onSelect(pageId)}
id={`notion-page-checkbox-${pageId}`}
/>
)
: (
<Radio
className="mr-2 shrink-0"
isChecked={checked}
disabled={disabled}
onCheck={() => onSelect(pageId)}
/>
)}
{!searchValue && row.hasChild && (
<div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: row.depth * 8 }}
onClick={() => onToggle(pageId)}
data-testid={`notion-page-toggle-${pageId}`}
>
{row.expand
? <RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
: <RiArrowRightSLine className="h-4 w-4 text-text-tertiary" />}
</div>
)}
{!searchValue && !row.hasChild && row.parentExists && (
<div className="mr-1 h-5 w-5 shrink-0" style={{ marginLeft: row.depth * 8 }} />
)}
<NotionIcon
className="mr-1 shrink-0"
type="page"
src={row.page.page_icon}
/>
<div
className="grow truncate text-[13px] leading-4 font-medium text-text-secondary"
title={row.page.page_name}
data-testid={`notion-page-name-${pageId}`}
>
{row.page.page_name}
</div>
{showPreview && (
<div
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
leading-4 font-medium text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
group-hover:flex hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover"
onClick={() => onPreview(pageId)}
data-testid={`notion-page-preview-${pageId}`}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>
)}
{searchValue && (
<div
className="ml-1 max-w-[120px] shrink-0 truncate text-xs text-text-quaternary"
title={breadcrumbs.join(' / ')}
>
{breadcrumbs.join(' / ')}
</div>
)}
</div>
)
}
export default memo(NotionPageRow)

View File

@@ -1,21 +0,0 @@
import type { DataSourceNotionPage } from '@/models/common'
export type NotionPageSelectionMode = 'multiple' | 'single'
export type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
export type NotionPageTreeMap = Record<string, NotionPageTreeItem>
export type NotionPageRow = {
page: DataSourceNotionPage
parentExists: boolean
depth: number
expand: boolean
hasChild: boolean
ancestors: string[]
}

View File

@@ -1,88 +0,0 @@
import type { NotionPageSelectionMode } from './types'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { startTransition, useCallback, useDeferredValue, useMemo, useState } from 'react'
import { buildNotionPageTree, getNextSelectedPageIds, getRootPageIds, getVisiblePageRows } from './utils'
type UsePageSelectorModelProps = {
checkedIds: Set<string>
searchValue: string
pagesMap: DataSourceNotionPageMap
list: DataSourceNotionPage[]
onSelect: (selectedPagesId: Set<string>) => void
previewPageId?: string
onPreview?: (selectedPageId: string) => void
selectionMode: NotionPageSelectionMode
}
export const usePageSelectorModel = ({
checkedIds,
searchValue,
pagesMap,
list,
onSelect,
previewPageId,
onPreview,
selectionMode,
}: UsePageSelectorModelProps) => {
const deferredSearchValue = useDeferredValue(searchValue)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [localPreviewPageId, setLocalPreviewPageId] = useState('')
const treeMap = useMemo(() => buildNotionPageTree(list, pagesMap), [list, pagesMap])
const rootPageIds = useMemo(() => getRootPageIds(list, pagesMap), [list, pagesMap])
const rows = useMemo(() => {
return getVisiblePageRows({
list,
pagesMap,
searchValue: deferredSearchValue,
treeMap,
rootPageIds,
expandedIds,
})
}, [deferredSearchValue, expandedIds, list, pagesMap, rootPageIds, treeMap])
const currentPreviewPageId = previewPageId ?? localPreviewPageId
const handleToggle = useCallback((pageId: string) => {
startTransition(() => {
setExpandedIds((currentExpandedIds) => {
const nextExpandedIds = new Set(currentExpandedIds)
if (nextExpandedIds.has(pageId)) {
nextExpandedIds.delete(pageId)
treeMap[pageId]?.descendants.forEach(descendantId => nextExpandedIds.delete(descendantId))
}
else {
nextExpandedIds.add(pageId)
}
return nextExpandedIds
})
})
}, [treeMap])
const handleSelect = useCallback((pageId: string) => {
onSelect(getNextSelectedPageIds({
checkedIds,
pageId,
searchValue: deferredSearchValue,
selectionMode,
treeMap,
}))
}, [checkedIds, deferredSearchValue, onSelect, selectionMode, treeMap])
const handlePreview = useCallback((pageId: string) => {
setLocalPreviewPageId(pageId)
onPreview?.(pageId)
}, [onPreview])
return {
currentPreviewPageId,
effectiveSearchValue: deferredSearchValue,
rows,
handlePreview,
handleSelect,
handleToggle,
}
}

View File

@@ -1,163 +0,0 @@
import type { NotionPageRow, NotionPageSelectionMode, NotionPageTreeItem, NotionPageTreeMap } from './types'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
export const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (!parentId || !pageId)
return
if (parentId !== 'root' && pagesMap[parentId]) {
if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
listTreeMap[parentId] = {
...pagesMap[parentId],
children,
descendants,
depth: 0,
ancestors: [],
}
}
else {
listTreeMap[parentId].children.add(pageId)
listTreeMap[parentId].descendants.add(pageId)
listTreeMap[parentId].descendants.add(leafItem.page_id)
}
leafItem.depth++
leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
if (listTreeMap[parentId].parent_id !== 'root')
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
}
}
export const buildNotionPageTree = (
list: DataSourceNotionPage[],
pagesMap: DataSourceNotionPageMap,
): NotionPageTreeMap => {
return list.reduce((prev: NotionPageTreeMap, next) => {
const pageId = next.page_id
if (!prev[pageId])
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
return prev
}, {})
}
export const getRootPageIds = (
list: DataSourceNotionPage[],
pagesMap: DataSourceNotionPageMap,
) => {
return list
.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id])
.map(item => item.page_id)
}
export const getVisiblePageRows = ({
list,
pagesMap,
searchValue,
treeMap,
rootPageIds,
expandedIds,
}: {
list: DataSourceNotionPage[]
pagesMap: DataSourceNotionPageMap
searchValue: string
treeMap: NotionPageTreeMap
rootPageIds: string[]
expandedIds: Set<string>
}): NotionPageRow[] => {
if (searchValue) {
return list
.filter(item => item.page_name.includes(searchValue))
.map(item => ({
page: item,
parentExists: item.parent_id !== 'root' && Boolean(pagesMap[item.parent_id]),
depth: treeMap[item.page_id]?.depth ?? 0,
expand: false,
hasChild: (treeMap[item.page_id]?.children.size ?? 0) > 0,
ancestors: treeMap[item.page_id]?.ancestors ?? [],
}))
}
const rows: NotionPageRow[] = []
const visit = (pageId: string) => {
const current = treeMap[pageId]
if (!current)
return
const expand = expandedIds.has(pageId)
rows.push({
page: current,
parentExists: current.parent_id !== 'root' && Boolean(pagesMap[current.parent_id]),
depth: current.depth,
expand,
hasChild: current.children.size > 0,
ancestors: current.ancestors,
})
if (!expand)
return
current.children.forEach(visit)
}
rootPageIds.forEach(visit)
return rows
}
export const getNextSelectedPageIds = ({
checkedIds,
pageId,
searchValue,
selectionMode,
treeMap,
}: {
checkedIds: Set<string>
pageId: string
searchValue: string
selectionMode: NotionPageSelectionMode
treeMap: NotionPageTreeMap
}) => {
const nextCheckedIds = new Set(checkedIds)
const descendants = treeMap[pageId]?.descendants ?? new Set<string>()
if (selectionMode === 'single') {
if (nextCheckedIds.has(pageId)) {
nextCheckedIds.delete(pageId)
}
else {
nextCheckedIds.clear()
nextCheckedIds.add(pageId)
}
return nextCheckedIds
}
if (nextCheckedIds.has(pageId)) {
if (!searchValue)
descendants.forEach(item => nextCheckedIds.delete(item))
nextCheckedIds.delete(pageId)
return nextCheckedIds
}
if (!searchValue)
descendants.forEach(item => nextCheckedIds.add(item))
nextCheckedIds.add(pageId)
return nextCheckedIds
}

View File

@@ -1,93 +0,0 @@
'use client'
import type { NotionPageRow, NotionPageSelectionMode } from './types'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
import PageRow from './page-row'
type VirtualPageListProps = {
checkedIds: Set<string>
disabledValue: Set<string>
onPreview: (pageId: string) => void
onSelect: (pageId: string) => void
onToggle: (pageId: string) => void
previewPageId: string
rows: NotionPageRow[]
searchValue: string
selectionMode: NotionPageSelectionMode
showPreview: boolean
}
const rowHeight = 28
const VirtualPageList = ({
checkedIds,
disabledValue,
onPreview,
onSelect,
onToggle,
previewPageId,
rows,
searchValue,
selectionMode,
showPreview,
}: VirtualPageListProps) => {
const scrollRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
estimateSize: () => rowHeight,
getScrollElement: () => scrollRef.current,
overscan: 6,
paddingEnd: 8,
paddingStart: 8,
})
const virtualRows = rowVirtualizer.getVirtualItems()
return (
<div
ref={scrollRef}
className="h-[296px] overflow-auto"
data-testid="virtual-list"
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]
const pageId = row.page.page_id
return (
<PageRow
key={pageId}
checked={checkedIds.has(pageId)}
disabled={disabledValue.has(pageId)}
isPreviewed={previewPageId === pageId}
onPreview={onPreview}
onSelect={onSelect}
onToggle={onToggle}
row={row}
searchValue={searchValue}
selectionMode={selectionMode}
showPreview={showPreview}
style={{
height: `${virtualRow.size}px`,
left: 8,
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: 'calc(100% - 16px)',
}}
/>
)
})}
</div>
</div>
)
}
export default VirtualPageList

View File

@@ -6,7 +6,7 @@
*
* Migration guide:
* - Tooltip → `@/app/components/base/ui/tooltip`
* - Menu/Dropdown → `@/app/components/base/ui/dropdown-menu`
* - Menu/Dropdown → `@langgenius/dify-ui/dropdown-menu`
* - Popover → `@/app/components/base/ui/popover`
* - Dialog/Modal → `@/app/components/base/ui/dialog`
* - Select → `@/app/components/base/ui/select`

View File

@@ -10,12 +10,6 @@ import { cn } from '@/utils/classnames'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
/** @public */
export const SelectGroup = BaseSelect.Group
/** @public */
export const SelectGroupLabel = BaseSelect.GroupLabel
/** @public */
export const SelectSeparator = BaseSelect.Separator
const selectTriggerVariants = cva(
'',

View File

@@ -106,7 +106,7 @@ const OnlineDocuments = ({
if (!currentCredentialId)
return
getOnlineDocuments()
}, [currentCredentialId, getOnlineDocuments])
}, [currentCredentialId])
const handleSearchValueChange = useCallback((value: string) => {
const { setSearchValue } = dataSourceStore.getState()
@@ -156,7 +156,6 @@ const OnlineDocuments = ({
{documentsData?.length
? (
<PageSelector
key={`${currentCredentialId}:${supportBatchUpload ? 'multiple' : 'single'}`}
checkedIds={selectedPagesId}
disabledValue={new Set()}
searchValue={searchValue}
@@ -166,6 +165,7 @@ const OnlineDocuments = ({
canPreview={!isInPipeline}
onPreview={handlePreviewPage}
isMultipleChoice={supportBatchUpload}
currentCredentialId={currentCredentialId}
/>
)
: (

View File

@@ -1,11 +1,26 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from '@/app/components/base/notion-page-selector/page-selector/types'
import type { NotionPageTreeItem, NotionPageTreeMap } from '../index'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { recursivePushInParentDescendants } from '@/app/components/base/notion-page-selector/page-selector/utils'
import PageSelector from '../index'
import { recursivePushInParentDescendants } from '../utils'
vi.mock('@tanstack/react-virtual')
// Mock react-window FixedSizeList - renders items directly for testing
vi.mock('react-window', () => ({
FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: { children: React.ComponentType<{ index: number, style: React.CSSProperties, data: unknown }>, itemCount: number, itemData: unknown, itemKey?: (index: number, data: unknown) => string | number }) => (
<div data-testid="virtual-list">
{Array.from({ length: itemCount }).map((_, index) => (
<ItemComponent
key={itemKey?.(index, itemData) || index}
index={index}
style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' as const }}
data={itemData}
/>
))}
</div>
),
areEqual: (prevProps: Record<string, unknown>, nextProps: Record<string, unknown>) => prevProps === nextProps,
}))
// Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines
@@ -55,6 +70,7 @@ const createDefaultProps = (overrides?: Partial<PageSelectorProps>): PageSelecto
canPreview: true,
onPreview: vi.fn(),
isMultipleChoice: true,
currentCredentialId: 'cred-1',
...overrides,
}
}
@@ -98,7 +114,7 @@ describe('PageSelector', () => {
expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument()
})
it('should render items using VirtualList', () => {
it('should render items using FixedSizeList', () => {
const pages = [
createMockPage({ page_id: 'page-1', page_name: 'Page 1' }),
createMockPage({ page_id: 'page-2', page_name: 'Page 2' }),

View File

@@ -1,7 +1,7 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from '@/app/components/base/notion-page-selector/page-selector/types'
import type { NotionPageTreeItem, NotionPageTreeMap } from '../index'
import type { DataSourceNotionPageMap } from '@/models/common'
import { describe, expect, it } from 'vitest'
import { recursivePushInParentDescendants } from '@/app/components/base/notion-page-selector/page-selector/utils'
import { recursivePushInParentDescendants } from '../utils'
const makePageEntry = (overrides: Partial<NotionPageTreeItem>): NotionPageTreeItem => ({
page_icon: null,

View File

@@ -1,7 +1,9 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { usePageSelectorModel } from '@/app/components/base/notion-page-selector/page-selector/use-page-selector-model'
import VirtualPageList from '@/app/components/base/notion-page-selector/page-selector/virtual-page-list'
import { FixedSizeList as List } from 'react-window'
import Item from './item'
import { recursivePushInParentDescendants } from './utils'
type PageSelectorProps = {
checkedIds: Set<string>
@@ -13,9 +15,23 @@ type PageSelectorProps = {
canPreview?: boolean
onPreview?: (selectedPageId: string) => void
isMultipleChoice?: boolean
currentCredentialId?: string
currentCredentialId: string
}
export type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
export type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
depth: number
} & DataSourceNotionPage
const PageSelector = ({
checkedIds,
disabledValue,
@@ -26,28 +42,116 @@ const PageSelector = ({
canPreview = true,
onPreview,
isMultipleChoice = true,
currentCredentialId: _currentCredentialId,
currentCredentialId,
}: PageSelectorProps) => {
const { t } = useTranslation()
const selectionMode = isMultipleChoice ? 'multiple' : 'single'
const {
currentPreviewPageId,
effectiveSearchValue,
rows,
handlePreview,
handleSelect,
handleToggle,
} = usePageSelectorModel({
checkedIds,
list,
onPreview,
onSelect,
pagesMap,
searchValue,
selectionMode,
})
const [dataList, setDataList] = useState<NotionPageItem[]>([])
const [currentPreviewPageId, setCurrentPreviewPageId] = useState('')
if (!rows.length) {
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [currentCredentialId])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id
if (!prev[pageId])
prev[pageId] = { ...next, children: new Set(), descendants: new Set(), depth: 0, ancestors: [] }
recursivePushInParentDescendants(pagesMap, prev, prev[pageId], prev[pageId])
return prev
}, {})
}, [list, pagesMap])
const handleToggle = useCallback((index: number) => {
const current = dataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants)
const childrenIds = Array.from(currentWithChildrenAndDescendants.children)
let newDataList = []
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
}
else {
current.expand = true
newDataList = [
...dataList.slice(0, index + 1),
...childrenIds.map(item => ({
...pagesMap[item],
expand: false,
depth: listMapWithChildrenAndDescendants[item].depth,
})),
...dataList.slice(index + 1),
]
}
setDataList(newDataList)
}, [dataList, listMapWithChildrenAndDescendants, pagesMap])
const handleCheck = useCallback((index: number) => {
const copyValue = new Set(checkedIds)
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item)
}
copyValue.delete(pageId)
}
else {
if (!searchValue && isMultipleChoice) {
for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item)
}
// Single choice mode, clear previous selection
if (!isMultipleChoice && copyValue.size > 0) {
copyValue.clear()
copyValue.add(pageId)
}
else {
copyValue.add(pageId)
}
}
onSelect(new Set(copyValue))
}, [currentDataList, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue, checkedIds])
const handlePreview = useCallback((index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
setCurrentPreviewPageId(pageId)
if (onPreview)
onPreview(pageId)
}, [currentDataList, onPreview])
if (!currentDataList.length) {
return (
<div className="flex h-[296px] items-center justify-center text-[13px] text-text-tertiary">
{t('dataSource.notion.selector.noSearchResult', { ns: 'common' })}
@@ -56,18 +160,30 @@ const PageSelector = ({
}
return (
<VirtualPageList
checkedIds={checkedIds}
disabledValue={disabledValue}
onPreview={handlePreview}
onSelect={handleSelect}
onToggle={handleToggle}
previewPageId={currentPreviewPageId}
rows={rows}
searchValue={effectiveSearchValue}
selectionMode={selectionMode}
showPreview={canPreview}
/>
<List
className="py-2"
height={296}
itemCount={currentDataList.length}
itemSize={28}
width="100%"
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
isMultipleChoice,
}}
>
{Item}
</List>
)
}

View File

@@ -0,0 +1,152 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { areEqual } from 'react-window'
import Checkbox from '@/app/components/base/checkbox'
import NotionIcon from '@/app/components/base/notion-icon'
import Radio from '@/app/components/base/radio/ui'
import { cn } from '@/utils/classnames'
type NotionPageTreeItem = {
children: Set<string>
descendants: Set<string>
depth: number
ancestors: string[]
} & DataSourceNotionPage
type NotionPageTreeMap = Record<string, NotionPageTreeItem>
type NotionPageItem = {
expand: boolean
depth: number
} & DataSourceNotionPage
const Item = ({ index, style, data }: ListChildComponentProps<{
dataList: NotionPageItem[]
handleToggle: (index: number) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
handleCheck: (index: number) => void
canPreview?: boolean
handlePreview: (index: number) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean
}>) => {
const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors
const breadCrumbs = ancestors.length ? [...ancestors, current.page_name] : [current.page_name]
const disabled = disabledCheckedIds.has(current.page_id)
const renderArrow = () => {
if (hasChild) {
return (
<div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)}
>
{
current.expand
? <RiArrowDownSLine className="h-4 w-4 text-text-tertiary" />
: <RiArrowRightSLine className="h-4 w-4 text-text-tertiary" />
}
</div>
)
}
if (current.parent_id === 'root' || !pagesMap[current.parent_id]) {
return (
<div></div>
)
}
return (
<div className="mr-1 h-5 w-5 shrink-0" style={{ marginLeft: current.depth * 8 }} />
)
}
return (
<div
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
>
{isMultipleChoice
? (
<Checkbox
className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
)
: (
<Radio
className="mr-2 shrink-0"
isChecked={checkedIds.has(current.page_id)}
disabled={disabled}
onCheck={() => {
handleCheck(index)
}}
/>
)}
{!searchValue && renderArrow()}
<NotionIcon
className="mr-1 shrink-0"
type="page"
src={current.page_icon}
/>
<div
className="grow truncate text-[13px] font-medium leading-4 text-text-secondary"
title={current.page_name}
>
{current.page_name}
</div>
{
canPreview && (
<div
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => handlePreview(index)}
>
{t('dataSource.notion.selector.preview', { ns: 'common' })}
</div>
)
}
{
searchValue && (
<div
className="ml-1 max-w-[120px] shrink-0 truncate text-xs text-text-quaternary"
title={breadCrumbs.join(' / ')}
>
{breadCrumbs.join(' / ')}
</div>
)
}
</div>
)
}
export default React.memo(Item, areEqual)

View File

@@ -0,0 +1,39 @@
import type { NotionPageTreeItem, NotionPageTreeMap } from './index'
import type { DataSourceNotionPageMap } from '@/models/common'
export const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap,
current: NotionPageTreeItem,
leafItem: NotionPageTreeItem,
) => {
const parentId = current.parent_id
const pageId = current.page_id
if (!parentId || !pageId)
return
if (parentId !== 'root' && pagesMap[parentId]) {
if (!listTreeMap[parentId]) {
const children = new Set([pageId])
const descendants = new Set([pageId, leafItem.page_id])
listTreeMap[parentId] = {
...pagesMap[parentId],
children,
descendants,
depth: 0,
ancestors: [],
}
}
else {
listTreeMap[parentId].children.add(pageId)
listTreeMap[parentId].descendants.add(pageId)
listTreeMap[parentId].descendants.add(leafItem.page_id)
}
leafItem.depth++
leafItem.ancestors.unshift(listTreeMap[parentId].page_name)
if (listTreeMap[parentId].parent_id !== 'root')
recursivePushInParentDescendants(pagesMap, listTreeMap, listTreeMap[parentId], leafItem)
}
}

View File

@@ -61,7 +61,7 @@ vi.mock('@/app/components/datasets/common/image-list', () => ({
),
}))
// Markdown uses next/dynamic and shiki (ESM)
// Markdown uses next/dynamic and react-syntax-highlighter (ESM)
vi.mock('@/app/components/base/markdown', () => ({
Markdown: ({ content, className }: { content: string, className?: string }) => (
<div data-testid="markdown" className={`markdown-body ${className || ''}`}>{content}</div>

View File

@@ -1,7 +1,7 @@
import type { ModalContextState } from '@/context/modal-context'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import { Plan } from '@/app/components/billing/type'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'

View File

@@ -1,7 +1,7 @@
import type { AppContextValue } from '@/context/app-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { fireEvent, render, screen } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'

View File

@@ -1,8 +1,8 @@
import type { ReactNode } from 'react'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { Plan } from '@/app/components/billing/type'

View File

@@ -1,13 +1,13 @@
'use client'
import type { MouseEventHandler, ReactNode } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
import { Avatar } from '@/app/components/base/avatar'
import PremiumBadge from '@/app/components/base/premium-badge'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'

View File

@@ -1,5 +1,5 @@
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
import { Plan } from '@/app/components/billing/type'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'

View File

@@ -1,15 +1,10 @@
import type { ReactNode } from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor'
import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce'
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { TONE_LIST } from '@/config'
const toneI18nKeyMap = {

View File

@@ -14,7 +14,7 @@ vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({ children, open }: { children: ReactNode, open: boolean }) => (
<div data-testid="dropdown-menu" data-open={open}>{children}</div>
),

View File

@@ -1,15 +1,9 @@
'use client'
import type { Placement } from '@langgenius/dify-ui/dropdown-menu'
import type { FC } from 'react'
import type { Placement } from '@/app/components/base/ui/placement'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { PluginSource } from '../types'

View File

@@ -1,14 +1,14 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
} from '@langgenius/dify-ui/context-menu'
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
} from '@/app/components/base/ui/context-menu'
import { useEdgesInteractions, usePanelInteractions } from './hooks'
import ShortcutsName from './shortcuts-name'
import { useStore } from './store'

View File

@@ -1,4 +1,12 @@
import type { Node } from './types'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
import { produce } from 'immer'
import {
memo,
@@ -8,14 +16,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
} from '@/app/components/base/ui/context-menu'
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'

View File

@@ -1,9 +1,5 @@
@import './preflight.css' layer(base);
@import '../../themes/light.css' layer(base);
@import '../../themes/dark.css' layer(base);
@import '../../themes/manual-light.css' layer(base);
@import '../../themes/manual-dark.css' layer(base);
@import '@langgenius/dify-ui/styles.css';
@import './monaco-sticky-fix.css' layer(base);
@import '../components/base/action-button/index.css';
@@ -17,727 +13,6 @@
@config '../../tailwind.config.ts';
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility system-kbd {
/* font define start */
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-2xs-regular-uppercase {
font-size: 10px;
font-weight: 400;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-regular {
font-size: 10px;
font-weight: 400;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-medium {
font-size: 10px;
font-weight: 500;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-medium-uppercase {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-2xs-semibold-uppercase {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
line-height: 12px;
/* border radius end */
}
@utility system-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility system-xs-regular-uppercase {
font-size: 12px;
font-weight: 400;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-xs-medium {
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-xs-medium-uppercase {
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-xs-semibold {
font-size: 12px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility system-xs-semibold-uppercase {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility system-sm-medium {
font-size: 13px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility system-sm-medium-uppercase {
font-size: 13px;
font-weight: 500;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-sm-semibold {
font-size: 13px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility system-sm-semibold-uppercase {
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
line-height: 16px;
/* border radius end */
}
@utility system-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility system-md-medium {
font-size: 14px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility system-md-semibold {
font-size: 14px;
font-weight: 600;
line-height: 20px;
/* border radius end */
}
@utility system-md-semibold-uppercase {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
line-height: 20px;
/* border radius end */
}
@utility system-xl-regular {
font-size: 16px;
font-weight: 400;
line-height: 24px;
/* border radius end */
}
@utility system-xl-medium {
font-size: 16px;
font-weight: 500;
line-height: 24px;
/* border radius end */
}
@utility system-xl-semibold {
font-size: 16px;
font-weight: 600;
line-height: 24px;
/* border radius end */
}
@utility code-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-xs-semibold {
font-size: 12px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility code-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-sm-semibold {
font-size: 13px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility code-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility code-md-semibold {
font-size: 14px;
font-weight: 600;
line-height: 1.5;
/* border radius end */
}
@utility body-xs-light {
font-size: 12px;
font-weight: 300;
line-height: 16px;
/* border radius end */
}
@utility body-xs-regular {
font-size: 12px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility body-xs-medium {
font-size: 12px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility body-sm-light {
font-size: 13px;
font-weight: 300;
line-height: 16px;
/* border radius end */
}
@utility body-sm-regular {
font-size: 13px;
font-weight: 400;
line-height: 16px;
/* border radius end */
}
@utility body-sm-medium {
font-size: 13px;
font-weight: 500;
line-height: 16px;
/* border radius end */
}
@utility body-md-light {
font-size: 14px;
font-weight: 300;
line-height: 20px;
/* border radius end */
}
@utility body-md-regular {
font-size: 14px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility body-md-medium {
font-size: 14px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility body-lg-light {
font-size: 15px;
font-weight: 300;
line-height: 20px;
/* border radius end */
}
@utility body-lg-regular {
font-size: 15px;
font-weight: 400;
line-height: 20px;
/* border radius end */
}
@utility body-lg-medium {
font-size: 15px;
font-weight: 500;
line-height: 20px;
/* border radius end */
}
@utility body-xl-regular {
font-size: 16px;
font-weight: 400;
line-height: 24px;
/* border radius end */
}
@utility body-xl-medium {
font-size: 16px;
font-weight: 500;
line-height: 24px;
/* border radius end */
}
@utility body-xl-light {
font-size: 16px;
font-weight: 300;
line-height: 24px;
/* border radius end */
}
@utility body-2xl-light {
font-size: 18px;
font-weight: 300;
line-height: 1.5;
/* border radius end */
}
@utility body-2xl-regular {
font-size: 18px;
font-weight: 400;
line-height: 1.5;
/* border radius end */
}
@utility body-2xl-medium {
font-size: 18px;
font-weight: 500;
line-height: 1.5;
/* border radius end */
}
@utility title-xs-semi-bold {
font-size: 12px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility title-xs-bold {
font-size: 12px;
font-weight: 700;
line-height: 16px;
/* border radius end */
}
@utility title-sm-semi-bold {
font-size: 13px;
font-weight: 600;
line-height: 16px;
/* border radius end */
}
@utility title-sm-bold {
font-size: 13px;
font-weight: 700;
line-height: 16px;
/* border radius end */
}
@utility title-md-semi-bold {
font-size: 14px;
font-weight: 600;
line-height: 20px;
/* border radius end */
}
@utility title-md-bold {
font-size: 14px;
font-weight: 700;
line-height: 20px;
/* border radius end */
}
@utility title-lg-semi-bold {
font-size: 15px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-lg-bold {
font-size: 15px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-xl-semi-bold {
font-size: 16px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-xl-bold {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-2xl-semi-bold {
font-size: 18px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-2xl-bold {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-3xl-semi-bold {
font-size: 20px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-3xl-bold {
font-size: 20px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-4xl-semi-bold {
font-size: 24px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-4xl-bold {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-5xl-semi-bold {
font-size: 30px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-5xl-bold {
font-size: 30px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-6xl-semi-bold {
font-size: 36px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-6xl-bold {
font-size: 36px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-7xl-semi-bold {
font-size: 48px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-7xl-bold {
font-size: 48px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility title-8xl-semi-bold {
font-size: 60px;
font-weight: 600;
line-height: 1.2;
/* border radius end */
}
@utility title-8xl-bold {
font-size: 60px;
font-weight: 700;
line-height: 1.2;
/* border radius end */
}
@utility radius-2xs {
/* font define end */
/* border radius start */
border-radius: 2px;
/* border radius end */
}
@utility radius-xs {
border-radius: 4px;
/* border radius end */
}
@utility radius-sm {
border-radius: 6px;
/* border radius end */
}
@utility radius-md {
border-radius: 8px;
/* border radius end */
}
@utility radius-lg {
border-radius: 10px;
/* border radius end */
}
@utility radius-xl {
border-radius: 12px;
/* border radius end */
}
@utility radius-2xl {
border-radius: 16px;
/* border radius end */
}
@utility radius-3xl {
border-radius: 20px;
/* border radius end */
}
@utility radius-4xl {
border-radius: 24px;
/* border radius end */
}
@utility radius-5xl {
border-radius: 24px;
/* border radius end */
}
@utility radius-6xl {
border-radius: 28px;
/* border radius end */
}
@utility radius-7xl {
border-radius: 32px;
/* border radius end */
}
@utility radius-8xl {
border-radius: 40px;
/* border radius end */
}
@utility radius-9xl {
border-radius: 48px;
/* border radius end */
}
@utility radius-full {
border-radius: 64px;
/* border radius end */
}
@utility no-scrollbar {
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
@utility no-spinner {
/* Hide arrows from number input */
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
-moz-appearance: textfield;
}
@layer components {
html {
color-scheme: light;
@@ -794,35 +69,6 @@
--card-border-rgb: 131, 134, 135;
}
/* @media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0));
--secondary-glow: linear-gradient(to bottom right,
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0),
rgba(1, 65, 255, 0.3));
--tile-start-rgb: 2, 13, 46;
--tile-end-rgb: 2, 5, 19;
--tile-border: conic-gradient(#ffffff80,
#ffffff40,
#ffffff30,
#ffffff20,
#ffffff10,
#ffffff10,
#ffffff80);
--callout-rgb: 20, 20, 20;
--callout-border-rgb: 108, 108, 108;
--card-rgb: 100, 100, 100;
--card-border-rgb: 200, 200, 200;
}
} */
* {
box-sizing: border-box;
padding: 0;
@@ -838,12 +84,6 @@
body {
color: rgb(var(--foreground-rgb));
user-select: none;
/* background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb)); */
}
a {
@@ -852,13 +92,6 @@
outline: none;
}
/* @media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
} */
/* CSS Utils */
.h1 {
padding-bottom: 1.5rem;
line-height: 1.5;
@@ -880,7 +113,7 @@
@layer components {
.link {
@apply text-blue-600 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out;
@apply cursor-pointer text-blue-600 transition-opacity duration-200 ease-in-out hover:opacity-80;
}
.text-gradient {
@@ -891,13 +124,11 @@
text-fill-color: transparent;
}
/* overwrite paging active dark model style */
[class*='style_paginatio'] li .text-primary-600 {
color: rgb(28 100 242);
background-color: rgb(235 245 255);
}
/* support safari 14 and below */
.inset-0 {
left: 0;
right: 0;
@@ -909,19 +140,4 @@
[data-theme='light'] [data-hide-on-theme='light'] {
display: none;
}
/* Shiki code block line numbers */
.shiki-line-numbers code {
counter-reset: line;
}
.shiki-line-numbers .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 1rem;
margin-right: 0.75rem;
text-align: right;
color: var(--color-text-quaternary);
user-select: none;
}
}

View File

@@ -1,5 +1,5 @@
@import '../../themes/markdown-light.css';
@import '../../themes/markdown-dark.css';
@import '@langgenius/dify-ui/markdown.css';
@reference "./globals.css";
.markdown-body {
-ms-text-size-adjust: 100%;
@@ -1070,6 +1070,9 @@
filter: invert(50%);
}
.markdown-body .react-syntax-highlighter-line-number {
color: var(--color-text-quaternary);
}
.markdown-body .abcjs-inline-audio .abcjs-btn {
display: flex !important;
}

View File

@@ -16,8 +16,8 @@ This document tracks the migration away from legacy overlay APIs.
- `@/app/components/base/toast` (including `context`)
- Replacement primitives:
- `@/app/components/base/ui/tooltip`
- `@/app/components/base/ui/dropdown-menu`
- `@/app/components/base/ui/context-menu`
- `@langgenius/dify-ui/dropdown-menu`
- `@langgenius/dify-ui/context-menu`
- `@/app/components/base/ui/popover`
- `@/app/components/base/ui/dialog`
- `@/app/components/base/ui/alert-dialog`

View File

@@ -10,7 +10,7 @@ When I ask you to write/refactor/fix tests, follow these rules by default.
- **Testing Tools**: Vitest 4.0.16 + React Testing Library 16.0
- **Test Environment**: happy-dom
- **File Naming**: `ComponentName.spec.tsx` inside a same-level `__tests__/` directory
- **Placement Rule**: Component, hook, and utility tests must live in a sibling `__tests__/` folder at the same level as the source under test. For example, `foo/index.tsx` maps to `foo/__tests__/index.spec.tsx`, and `foo/bar.ts` maps to `foo/__tests__/bar.spec.ts`.
- **Placement Rule**: Component, hook, and utility tests must live in a sibling `__tests__/` folder at the same level as the source under test. For example, `foo/index.tsx` maps to `foo/__tests__/index.spec.tsx`, and `foo/bar.ts` maps to `foo/__tests__/bar.spec.ts`. This rule also applies to workspace packages under `packages/`.
## Running Tests

View File

@@ -3142,6 +3142,9 @@
"react/set-state-in-effect": {
"count": 7
},
"tailwindcss/enforce-consistent-class-order": {
"count": 1
},
"ts/no-explicit-any": {
"count": 9
}
@@ -3328,6 +3331,11 @@
"count": 2
}
},
"app/components/base/notion-page-selector/base.tsx": {
"react/set-state-in-effect": {
"count": 2
}
},
"app/components/base/notion-page-selector/credential-selector/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
@@ -3343,6 +3351,14 @@
"count": 1
}
},
"app/components/base/notion-page-selector/page-selector/index.tsx": {
"react/set-state-in-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/base/notion-page-selector/search-input/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1
@@ -4818,6 +4834,16 @@
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx": {
"react/set-state-in-effect": {
"count": 1
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/title.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 1

View File

@@ -71,7 +71,7 @@ export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [
'**/base/dropdown',
'**/base/dropdown/index',
],
message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.',
message: 'Deprecated: use @langgenius/dify-ui/dropdown-menu instead. See issue #32767.',
},
{
group: [

View File

@@ -135,9 +135,6 @@
"dataSource.notion.pagesAuthorized": "Pages authorized",
"dataSource.notion.remove": "Remove",
"dataSource.notion.selector.addPages": "Add pages",
"dataSource.notion.selector.configure": "Configure Notion",
"dataSource.notion.selector.docs": "Notion docs",
"dataSource.notion.selector.headerTitle": "Choose Notion pages",
"dataSource.notion.selector.noSearchResult": "No search results",
"dataSource.notion.selector.pageSelected": "Pages Selected",
"dataSource.notion.selector.preview": "PREVIEW",

View File

@@ -25,6 +25,7 @@
"analyze": "next experimental-analyze",
"analyze-component": "node ./scripts/analyze-component.js",
"build": "next build",
"build:dify-ui": "pnpm --filter @langgenius/dify-ui build",
"build:vinext": "vinext build",
"dev": "next dev",
"dev:inspect": "next dev --inspect",
@@ -40,6 +41,16 @@
"lint:quiet": "vp run lint --quiet",
"lint:tss": "tsslint --project tsconfig.json",
"preinstall": "npx only-allow pnpm",
"prebuild": "pnpm run build:dify-ui",
"prebuild:vinext": "pnpm run build:dify-ui",
"predev": "pnpm run build:dify-ui",
"predev:vinext": "pnpm run build:dify-ui",
"prestorybook": "pnpm run build:dify-ui",
"prestorybook:build": "pnpm run build:dify-ui",
"pretest": "pnpm run build:dify-ui",
"pretest:watch": "pnpm run build:dify-ui",
"pretype-check": "pnpm run build:dify-ui",
"pretype-check:tsgo": "pnpm run build:dify-ui",
"refactor-component": "node ./scripts/refactor-component.js",
"start": "node ./scripts/copy-and-start.mjs",
"start:vinext": "vinext start",
@@ -61,6 +72,7 @@
"@formatjs/intl-localematcher": "catalog:",
"@headlessui/react": "catalog:",
"@heroicons/react": "catalog:",
"@langgenius/dify-ui": "workspace:*",
"@lexical/code": "catalog:",
"@lexical/link": "catalog:",
"@lexical/list": "catalog:",
@@ -81,7 +93,6 @@
"@tailwindcss/typography": "catalog:",
"@tanstack/react-form": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:",
"abcjs": "catalog:",
"ahooks": "catalog:",
"class-variance-authority": "catalog:",
@@ -101,7 +112,6 @@
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"foxact": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",
"html-to-image": "catalog:",
"i18next": "catalog:",
@@ -136,13 +146,14 @@
"react-papaparse": "catalog:",
"react-pdf-highlighter": "catalog:",
"react-sortablejs": "catalog:",
"react-syntax-highlighter": "catalog:",
"react-textarea-autosize": "catalog:",
"react-window": "catalog:",
"reactflow": "catalog:",
"remark-breaks": "catalog:",
"remark-directive": "catalog:",
"scheduler": "catalog:",
"sharp": "catalog:",
"shiki": "catalog:",
"sortablejs": "catalog:",
"std-semver": "catalog:",
"streamdown": "catalog:",
@@ -197,6 +208,8 @@
"@types/qs": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/react-syntax-highlighter": "catalog:",
"@types/react-window": "catalog:",
"@types/sortablejs": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@typescript/native-preview": "catalog:",

View File

@@ -1,5 +1,5 @@
import type { Config } from 'tailwindcss'
import commonConfig from './tailwind-common-config'
import difyUiTailwindPreset from '@langgenius/dify-ui/tailwind-preset'
const config: Config = {
content: [
@@ -10,7 +10,7 @@ const config: Config = {
'./node_modules/@streamdown/math/dist/*.js',
'!./**/*.{spec,test}.{js,ts,jsx,tsx}',
],
...commonConfig,
...difyUiTailwindPreset,
}
export default config