Compare commits

..

6 Commits

175 changed files with 926 additions and 11033 deletions

View File

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import timedelta
from enum import StrEnum
from typing import Literal
@ -1094,16 +1094,6 @@ class HomepageConfig(BaseSettings):
default=True,
)
ENABLE_STEP_BY_STEP_TOUR: bool = Field(
description="Enable account-level Step-by-step Tour eligibility checks",
default=False,
)
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT: datetime | None = Field(
description="UTC timestamp after which newly initialized accounts are eligible for Step-by-step Tour",
default=None,
)
class RagEtlConfig(BaseSettings):
"""

View File

@ -39,7 +39,6 @@ from . import (
human_input_form,
init_validate,
notification,
onboarding,
ping,
setup,
spec,
@ -207,7 +206,6 @@ __all__ = [
"notification",
"oauth",
"oauth_server",
"onboarding",
"ops_trace",
"parameter",
"ping",

View File

@ -1,107 +0,0 @@
"""Console onboarding APIs.
This module keeps Step-by-step Tour persistence account-scoped. Workspace IDs
are accepted only as presentation overrides; UI-only state such as minimized
panels or the currently active task stays on the frontend. PATCH requests are
action-based so callers do not replace server-side arrays with stale snapshots.
"""
from datetime import datetime
from typing import Literal, cast
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, model_validator
from controllers.common.schema import register_response_schema_models, register_schema_models
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
from . import console_ns
from .wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user
StepByStepTourAction = Literal[
"skip",
"complete_task",
"uncomplete_task",
"enable_current_workspace",
"disable_current_workspace",
]
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
class StepByStepTourStatePatchPayload(BaseModel):
action: StepByStepTourAction = Field(description="State update action")
task_id: StepByStepTourTaskId | None = Field(default=None, description="Task ID for task actions")
model_config = ConfigDict(extra="forbid")
@model_validator(mode="after")
def validate_patch_shape(self) -> "StepByStepTourStatePatchPayload":
task_actions = {"complete_task", "uncomplete_task"}
if self.action in task_actions and self.task_id is None:
raise ValueError("task_id is required for task actions")
if self.action not in task_actions and self.task_id is not None:
raise ValueError("task_id is only supported for task actions")
return self
class StepByStepTourStateResponse(ResponseModel):
eligible: bool
first_workspace_id: str | None = None
skipped: bool = False
completed_task_ids: list[StepByStepTourTaskId] = Field(default_factory=list)
manually_enabled_workspace_ids: list[str] = Field(default_factory=list)
manually_disabled_workspace_ids: list[str] = Field(default_factory=list)
updated_at: datetime | None = None
register_schema_models(console_ns, StepByStepTourStatePatchPayload)
register_response_schema_models(console_ns, StepByStepTourStateResponse)
@console_ns.route("/onboarding/step-by-step-tour/state")
class StepByStepTourStateApi(Resource):
@console_ns.doc("get_step_by_step_tour_state")
@console_ns.doc(description="Get account-level Step-by-step Tour state")
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
return dump_response(
StepByStepTourStateResponse,
StepByStepTourService.get_state(
account=current_user,
current_tenant_id=current_tenant_id,
session=db.session,
),
)
@console_ns.doc("patch_step_by_step_tour_state")
@console_ns.doc(description="Update account-level Step-by-step Tour state")
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account):
payload = StepByStepTourStatePatchPayload.model_validate(console_ns.payload or {})
patch = cast(StepByStepTourPatch, payload.model_dump(exclude_unset=True, exclude_none=True))
return dump_response(
StepByStepTourStateResponse,
StepByStepTourService.patch_state(
account=current_user,
current_tenant_id=current_tenant_id,
patch=patch,
session=db.session,
),
)

View File

@ -1,39 +0,0 @@
"""add step by step tour state
Revision ID: e4f5a6b7c8d9
Revises: d9e8f7a6b5c4
Create Date: 2026-06-29 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "e4f5a6b7c8d9"
down_revision = "d9e8f7a6b5c4"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"account_step_by_step_tour_states",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("first_workspace_id", models.types.StringUUID(), nullable=True),
sa.Column("skipped", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("completed_task_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("manually_enabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("manually_disabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
)
def downgrade():
op.drop_table("account_step_by_step_tour_states")

View File

@ -101,7 +101,6 @@ from .model import (
UploadFile,
)
from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken
from .onboarding import AccountStepByStepTourState
from .provider import (
LoadBalancingModelConfig,
Provider,
@ -155,7 +154,6 @@ __all__ = [
"Account",
"AccountIntegrate",
"AccountStatus",
"AccountStepByStepTourState",
"AccountTrialAppRecord",
"Agent",
"AgentConfigDraft",

View File

@ -1,59 +0,0 @@
"""Account-level onboarding state models."""
from datetime import datetime
import sqlalchemy as sa
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import TypeBase, gen_uuidv7_string
from .types import AdjustedJSON, StringUUID
class AccountStepByStepTourState(TypeBase):
"""Persistent account-level Step-by-step Tour state.
The tour is account-owned, with workspace IDs stored only as presentation
overrides. The first workspace is the workspace context where an eligible
account first asks for tour state; subsequent workspaces are opt-in only.
"""
__tablename__ = "account_step_by_step_tour_states"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
)
id: Mapped[str] = mapped_column(
StringUUID,
insert_default=gen_uuidv7_string,
default_factory=gen_uuidv7_string,
init=False,
)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
first_workspace_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
skipped: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
completed_task_ids: Mapped[list[str]] = mapped_column(AdjustedJSON, nullable=False, default_factory=list)
manually_enabled_workspace_ids: Mapped[list[str]] = mapped_column(
AdjustedJSON,
nullable=False,
default_factory=list,
)
manually_disabled_workspace_ids: Mapped[list[str]] = mapped_column(
AdjustedJSON,
nullable=False,
default_factory=list,
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.current_timestamp(),
nullable=False,
init=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.current_timestamp(),
nullable=False,
init=False,
onupdate=func.current_timestamp(),
)

View File

@ -182,7 +182,6 @@ class SystemFeatureModel(FeatureResponseModel):
enable_trial_app: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
rbac_enabled: bool = False
@ -285,7 +284,6 @@ class FeatureService:
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
@classmethod
def _fulfill_trial_models_from_env(cls) -> list[str]:

View File

@ -416,9 +416,10 @@ class PluginMigration:
data = _tenant_plugin_adapter.validate_json(line)
tenant_id = data["tenant_id"]
plugin_ids = data["plugins"]
plugin_not_exist = [
plugin_id for plugin_id in plugin_ids if plugin_id not in package_identifier_by_plugin_id
]
plugin_not_exist: list[str] = []
for plugin_id in plugin_ids:
if plugin_id not in package_identifier_by_plugin_id:
plugin_not_exist.append(plugin_id)
if plugin_not_exist:
not_installed.append(

View File

@ -1,225 +0,0 @@
"""Account-level Step-by-step Tour persistence."""
from datetime import datetime
from typing import NotRequired, TypedDict
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, scoped_session
from configs import dify_config
from libs.datetime_utils import ensure_naive_utc
from models.account import Account
from models.onboarding import AccountStepByStepTourState
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
class StepByStepTourStateResponse(TypedDict):
eligible: bool
first_workspace_id: str | None
skipped: bool
completed_task_ids: list[str]
manually_enabled_workspace_ids: list[str]
manually_disabled_workspace_ids: list[str]
updated_at: datetime | None
class StepByStepTourPatch(TypedDict):
action: str
task_id: NotRequired[str | None]
class StepByStepTourService:
"""Coordinate persisted tour state with account eligibility rules."""
@classmethod
def get_state(
cls,
*,
account: Account,
current_tenant_id: str,
session: Session | scoped_session,
) -> StepByStepTourStateResponse:
eligible = cls.is_eligible(account)
state = cls._get_state(account.id, session=session)
if eligible:
state = cls._ensure_state(account.id, session=session, state=state)
if state.first_workspace_id is None:
state.first_workspace_id = current_tenant_id
session.commit()
session.refresh(state)
return cls._build_response(eligible=eligible, state=state)
@classmethod
def patch_state(
cls,
*,
account: Account,
current_tenant_id: str,
patch: StepByStepTourPatch,
session: Session | scoped_session,
) -> StepByStepTourStateResponse:
state = cls._ensure_state(account.id, session=session, state=None)
cls._apply_action(
state=state,
action=patch["action"],
task_id=patch.get("task_id"),
current_tenant_id=current_tenant_id,
)
session.commit()
session.refresh(state)
return cls._build_response(eligible=cls.is_eligible(account), state=state)
@classmethod
def is_eligible(cls, account: Account) -> bool:
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
return False
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
if rollout_started_at is None:
return False
account_started_at = account.initialized_at or account.created_at
if account_started_at is None:
return False
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
@classmethod
def _get_state(
cls,
account_id: str,
*,
session: Session | scoped_session,
) -> AccountStepByStepTourState | None:
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
return session.execute(stmt).scalar_one_or_none()
@classmethod
def _ensure_state(
cls,
account_id: str,
*,
session: Session | scoped_session,
state: AccountStepByStepTourState | None,
) -> AccountStepByStepTourState:
if state is None:
state = cls._get_state(account_id, session=session)
if state is not None:
return state
state = AccountStepByStepTourState(account_id=account_id)
session.add(state)
try:
session.flush()
except IntegrityError:
# Another tab/device can create the account row between our read and insert.
session.rollback()
state = cls._get_state(account_id, session=session)
if state is None:
raise
return state
@classmethod
def _apply_action(
cls,
*,
state: AccountStepByStepTourState,
action: str,
task_id: str | None,
current_tenant_id: str,
) -> None:
match action:
case "skip":
state.skipped = True
state.manually_enabled_workspace_ids = cls._remove_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
case "complete_task":
if task_id is None:
raise ValueError("task_id is required")
cls._validate_task_id(task_id)
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
case "uncomplete_task":
if task_id is None:
raise ValueError("task_id is required")
cls._validate_task_id(task_id)
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
case "enable_current_workspace":
state.skipped = False
state.manually_enabled_workspace_ids = cls._add_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
state.manually_disabled_workspace_ids = cls._remove_id(
state.manually_disabled_workspace_ids,
current_tenant_id,
)
case "disable_current_workspace":
state.manually_enabled_workspace_ids = cls._remove_id(
state.manually_enabled_workspace_ids,
current_tenant_id,
)
state.manually_disabled_workspace_ids = cls._add_id(
state.manually_disabled_workspace_ids,
current_tenant_id,
)
case _:
raise ValueError(f"Unsupported action: {action}")
@classmethod
def _build_response(
cls,
*,
eligible: bool,
state: AccountStepByStepTourState | None,
) -> StepByStepTourStateResponse:
if state is None:
return {
"eligible": eligible,
"first_workspace_id": None,
"skipped": False,
"completed_task_ids": [],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": None,
}
return {
"eligible": eligible,
"first_workspace_id": state.first_workspace_id,
"skipped": state.skipped,
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
"updated_at": getattr(state, "updated_at", None),
}
@staticmethod
def _validate_task_id(task_id: str) -> None:
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
raise ValueError(f"Unsupported task_id: {task_id}")
@classmethod
def _add_id(cls, values: list[str], value: str) -> list[str]:
normalized = cls._normalize_ids(values)
if value in normalized:
return normalized
return [*normalized, value]
@classmethod
def _remove_id(cls, values: list[str], value: str) -> list[str]:
return [item for item in cls._normalize_ids(values) if item != value]
@staticmethod
def _normalize_ids(values: list[str]) -> list[str]:
normalized: list[str] = []
for value in values:
if value not in normalized:
normalized.append(value)
return normalized

View File

@ -1,101 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
from inspect import unwrap
from unittest.mock import Mock, PropertyMock, patch
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.console import console_ns
from controllers.console.onboarding import (
StepByStepTourStateApi,
StepByStepTourStatePatchPayload,
)
from extensions.ext_database import db
from models.account import Account, AccountStatus
from services.step_by_step_tour_service import StepByStepTourService
def _account() -> Account:
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
return account
def _state_response() -> dict[str, object]:
return {
"eligible": True,
"first_workspace_id": "workspace-1",
"skipped": False,
"completed_task_ids": ["home"],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
}
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
get_state = Mock(return_value=_state_response())
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
api = StepByStepTourStateApi()
method = unwrap(api.get)
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
result = method(api, "workspace-1", _account())
assert result == {
"eligible": True,
"first_workspace_id": "workspace-1",
"skipped": False,
"completed_task_ids": ["home"],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": "2026-06-28T00:00:00Z",
}
get_state.assert_called_once()
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
assert get_state.call_args.kwargs["session"] is db.session
def test_patch_step_by_step_tour_state_passes_action_payload(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
patch_state = Mock(return_value=_state_response())
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
api = StepByStepTourStateApi()
method = unwrap(api.patch)
payload = {"action": "complete_task", "task_id": "studio"}
with app.test_request_context(
"/console/api/onboarding/step-by-step-tour/state",
method="PATCH",
json=payload,
):
with patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload):
result = method(api, "workspace-1", _account())
assert result["completed_task_ids"] == ["home"]
patch_state.assert_called_once()
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
assert patch_state.call_args.kwargs["patch"] == payload
assert patch_state.call_args.kwargs["session"] is db.session
def test_patch_payload_rejects_non_action_fields() -> None:
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "skipped": True})
def test_patch_payload_rejects_task_id_without_task_action() -> None:
with pytest.raises(ValidationError, match="task_id is only supported for task actions"):
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "task_id": "home"})
def test_patch_payload_requires_action() -> None:
with pytest.raises(ValidationError):
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})

View File

@ -148,35 +148,5 @@ class TestHandlePluginInstanceInstall:
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
assert json.loads(output_file.read_text())["not_installed"] == [
{
"tenant_id": "tenant1",
"plugin_not_exist": ["langgenius/missing"],
}
{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}
]
mock_installer.install_from_identifiers.assert_called_once()
def test_install_plugins_skips_unresolved_plugins(self, tmp_path) -> None:
extracted_plugins = tmp_path / "plugins.jsonl"
output_file = tmp_path / "output.json"
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/missing"]}\n')
with (
patch(
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
return_value={
"plugins": {},
"plugin_not_exist": ["langgenius/missing"],
},
),
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
):
mock_installer = MagicMock()
mock_installer.list_plugins.return_value = []
mock_installer_cls.return_value = mock_installer
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
output = json.loads(output_file.read_text())
assert output["not_installed"] == [{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}]
mock_installer.install_from_identifiers.assert_not_called()

View File

@ -633,19 +633,6 @@ def test_import_rag_pipeline_yaml_content_requires_content() -> None:
assert "yaml_content is required" in result.error
def test_import_rag_pipeline_rejects_oversized_yaml_content_before_parsing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 3)
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content="你你")
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
def test_import_rag_pipeline_yaml_content_requires_mapping() -> None:
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")

View File

@ -1,49 +1,50 @@
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from services.app_dsl_service import AppDslService
from services.entities.dsl_entities import ImportStatus
from services.app_dsl_service import AppDslService, ImportStatus
def test_import_app_rejects_oversized_yaml_content_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 3)
service = AppDslService(session=Mock())
account = Mock(current_tenant_id="tenant-1")
def test_import_app_rejects_oversized_yaml_content_by_bytes(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
service = AppDslService(session=SimpleNamespace())
result = service.import_app(account=account, import_mode="yaml-content", yaml_content="你你")
result = service.import_app(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-content",
yaml_content="é",
)
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
assert "10MB" in result.error
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None:
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=Mock())
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=Mock(current_tenant_id="tenant-1"),
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)
assert result.status == ImportStatus.FAILED
assert result.error == "File size exceeds the limit of 10MB"
assert "10MB" in result.error
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=Mock())
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=Mock(current_tenant_id="tenant-1"),
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)

View File

@ -6,7 +6,6 @@ from services.feature_service import FeatureService, SystemFeatureModel
def test_system_feature_model_defaults_enable_learn_app():
assert SystemFeatureModel().enable_learn_app is True
assert SystemFeatureModel().enable_step_by_step_tour is False
@pytest.mark.parametrize("enabled", [True, False])
@ -16,14 +15,3 @@ def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPa
result = FeatureService.get_system_features()
assert result.enable_learn_app is enabled
@pytest.mark.parametrize("enabled", [True, False])
def test_get_system_features_reads_enable_step_by_step_tour(
monkeypatch: pytest.MonkeyPatch, enabled: bool
) -> None:
monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
result = FeatureService.get_system_features()
assert result.enable_step_by_step_tour is enabled

View File

@ -1,242 +0,0 @@
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from sqlalchemy.exc import IntegrityError
from models.account import Account, AccountStatus
from models.onboarding import AccountStepByStepTourState
from services import step_by_step_tour_service as service_module
from services.step_by_step_tour_service import StepByStepTourService
class _ScalarResult:
def __init__(self, state: AccountStepByStepTourState | None) -> None:
self._state = state
def scalar_one_or_none(self) -> AccountStepByStepTourState | None:
return self._state
class _FakeSession:
def __init__(self, state: AccountStepByStepTourState | None = None) -> None:
self.state = state
self.added: list[AccountStepByStepTourState] = []
self.commit_count = 0
self.flush_count = 0
self.refresh_count = 0
self.rollback_count = 0
def execute(self, _stmt) -> _ScalarResult:
return _ScalarResult(self.state)
def add(self, state: AccountStepByStepTourState) -> None:
self.state = state
self.added.append(state)
def flush(self) -> None:
self.flush_count += 1
def commit(self) -> None:
self.commit_count += 1
def refresh(self, state: AccountStepByStepTourState) -> None:
self.refresh_count += 1
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
def rollback(self) -> None:
self.rollback_count += 1
class _RaceInsertSession(_FakeSession):
def __init__(self, state_after_rollback: AccountStepByStepTourState) -> None:
super().__init__(state=None)
self.state_after_rollback = state_after_rollback
def flush(self) -> None:
self.flush_count += 1
raise IntegrityError("insert", {}, Exception("duplicate"))
def rollback(self) -> None:
super().rollback()
self.state = self.state_after_rollback
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
account.id = "account-1"
account.initialized_at = initialized_at
account.created_at = created_at or datetime(2026, 6, 28)
return account
def _state() -> AccountStepByStepTourState:
state = AccountStepByStepTourState(account_id="account-1")
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
return state
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at)
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.get_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
session=session,
)
assert result["eligible"] is True
assert result["first_workspace_id"] == "workspace-1"
assert result["completed_task_ids"] == []
assert len(session.added) == 1
assert session.added[0].account_id == "account-1"
assert session.commit_count == 1
assert session.refresh_count == 1
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
assert result is True
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.get_state(
account=_account(initialized_at=datetime(2026, 5, 31)),
current_tenant_id="workspace-1",
session=session,
)
assert result == {
"eligible": False,
"first_workspace_id": None,
"skipped": False,
"completed_task_ids": [],
"manually_enabled_workspace_ids": [],
"manually_disabled_workspace_ids": [],
"updated_at": None,
}
assert session.added == []
assert session.commit_count == 0
def test_patch_state_persists_even_when_account_is_not_eligible(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
session = _FakeSession()
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-2",
patch={"action": "enable_current_workspace"},
session=session,
)
assert result["eligible"] is False
assert result["skipped"] is False
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == []
assert len(session.added) == 1
assert session.commit_count == 1
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
session = _FakeSession(state=state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "skip"},
session=session,
)
assert result["eligible"] is False
assert result["skipped"] is True
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == []
assert session.added == []
assert session.commit_count == 1
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
session = _FakeSession(state=state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "disable_current_workspace"},
session=session,
)
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
assert session.commit_count == 1
def test_patch_state_complete_and_uncomplete_task(monkeypatch: pytest.MonkeyPatch) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
state = _state()
state.completed_task_ids = ["home"]
session = _FakeSession(state=state)
StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "complete_task", "task_id": "studio"},
session=session,
)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-1",
patch={"action": "uncomplete_task", "task_id": "home"},
session=session,
)
assert result["completed_task_ids"] == ["studio"]
def test_patch_state_recovers_when_concurrent_request_created_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
existing_state = _state()
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
session = _RaceInsertSession(state_after_rollback=existing_state)
result = StepByStepTourService.patch_state(
account=_account(initialized_at=datetime(2026, 6, 28)),
current_tenant_id="workspace-2",
patch={"action": "enable_current_workspace"},
session=session,
)
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
assert session.flush_count == 1
assert session.rollback_count == 1
assert session.commit_count == 1

View File

@ -2759,14 +2759,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/components/list.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/documents/components/rename-modal.tsx": {
"no-restricted-imports": {
"count": 1
@ -3113,27 +3105,6 @@
"count": 1
}
},
"web/app/components/datasets/hit-testing/index.tsx": {
"react/unsupported-syntax": {
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1

View File

@ -1,56 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zGetOnboardingStepByStepTourStateResponse,
zPatchOnboardingStepByStepTourStateBody,
zPatchOnboardingStepByStepTourStateResponse,
} from './zod.gen'
/**
* Get account-level Step-by-step Tour state
*/
export const get = oc
.route({
description: 'Get account-level Step-by-step Tour state',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getOnboardingStepByStepTourState',
path: '/onboarding/step-by-step-tour/state',
tags: ['console'],
})
.output(zGetOnboardingStepByStepTourStateResponse)
/**
* Update account-level Step-by-step Tour state
*/
export const patch = oc
.route({
description: 'Update account-level Step-by-step Tour state',
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'patchOnboardingStepByStepTourState',
path: '/onboarding/step-by-step-tour/state',
tags: ['console'],
})
.input(z.object({ body: zPatchOnboardingStepByStepTourStateBody }))
.output(zPatchOnboardingStepByStepTourStateResponse)
export const state = {
get,
patch,
}
export const stepByStepTour = {
state,
}
export const onboarding = {
stepByStepTour,
}
export const contract = {
onboarding,
}

View File

@ -1,53 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
export type ClientOptions = {
baseUrl: `${string}://${string}/console/api` | (string & {})
}
export type StepByStepTourStateResponse = {
completed_task_ids?: Array<'home' | 'integration' | 'knowledge' | 'studio'>
eligible: boolean
first_workspace_id?: string | null
manually_disabled_workspace_ids?: Array<string>
manually_enabled_workspace_ids?: Array<string>
skipped?: boolean
updated_at?: string | null
}
export type StepByStepTourStatePatchPayload = {
action:
| 'complete_task'
| 'disable_current_workspace'
| 'enable_current_workspace'
| 'skip'
| 'uncomplete_task'
task_id?: 'home' | 'integration' | 'knowledge' | 'studio' | null
}
export type GetOnboardingStepByStepTourStateData = {
body?: never
path?: never
query?: never
url: '/onboarding/step-by-step-tour/state'
}
export type GetOnboardingStepByStepTourStateResponses = {
200: StepByStepTourStateResponse
}
export type GetOnboardingStepByStepTourStateResponse
= GetOnboardingStepByStepTourStateResponses[keyof GetOnboardingStepByStepTourStateResponses]
export type PatchOnboardingStepByStepTourStateData = {
body: StepByStepTourStatePatchPayload
path?: never
query?: never
url: '/onboarding/step-by-step-tour/state'
}
export type PatchOnboardingStepByStepTourStateResponses = {
200: StepByStepTourStateResponse
}
export type PatchOnboardingStepByStepTourStateResponse
= PatchOnboardingStepByStepTourStateResponses[keyof PatchOnboardingStepByStepTourStateResponses]

View File

@ -1,42 +0,0 @@
// This file is auto-generated by @hey-api/openapi-ts
import * as z from 'zod'
/**
* StepByStepTourStateResponse
*/
export const zStepByStepTourStateResponse = z.object({
completed_task_ids: z.array(z.enum(['home', 'integration', 'knowledge', 'studio'])).optional(),
eligible: z.boolean(),
first_workspace_id: z.string().nullish(),
manually_disabled_workspace_ids: z.array(z.string()).optional(),
manually_enabled_workspace_ids: z.array(z.string()).optional(),
skipped: z.boolean().optional().default(false),
updated_at: z.iso.datetime().nullish(),
})
/**
* StepByStepTourStatePatchPayload
*/
export const zStepByStepTourStatePatchPayload = z.object({
action: z.enum([
'complete_task',
'disable_current_workspace',
'enable_current_workspace',
'skip',
'uncomplete_task',
]),
task_id: z.enum(['home', 'integration', 'knowledge', 'studio']).nullish(),
})
/**
* Success
*/
export const zGetOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse
export const zPatchOnboardingStepByStepTourStateBody = zStepByStepTourStatePatchPayload
/**
* Success
*/
export const zPatchOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse

View File

@ -47,7 +47,6 @@ export const contractLoaders = {
import('./notification/orpc.gen').then(({ notification }) => ({ notification })),
notion: () => import('./notion/orpc.gen').then(({ notion }) => ({ notion })),
oauth: () => import('./oauth/orpc.gen').then(({ oauth }) => ({ oauth })),
onboarding: () => import('./onboarding/orpc.gen').then(({ onboarding }) => ({ onboarding })),
rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })),
refreshToken: () =>
import('./refresh-token/orpc.gen').then(({ refreshToken }) => ({ refreshToken })),

View File

@ -31,7 +31,6 @@ import { logout } from './logout/orpc.gen'
import { notification } from './notification/orpc.gen'
import { notion } from './notion/orpc.gen'
import { oauth } from './oauth/orpc.gen'
import { onboarding } from './onboarding/orpc.gen'
import { rag } from './rag/orpc.gen'
import { refreshToken } from './refresh-token/orpc.gen'
import { remoteFiles } from './remote-files/orpc.gen'
@ -83,7 +82,6 @@ const communityContract = {
notification,
notion,
oauth,
onboarding,
rag,
refreshToken,
remoteFiles,

View File

@ -16,7 +16,6 @@ export type SystemFeatureModel = {
enable_learn_app: boolean
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_create_workspace: boolean
is_allow_register: boolean

View File

@ -108,7 +108,6 @@ export const zSystemFeatureModel = z.object({
enable_learn_app: z.boolean().default(true),
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_create_workspace: z.boolean().default(false),
is_allow_register: z.boolean().default(false),

View File

@ -564,7 +564,6 @@ export type SystemFeatureModel = {
enable_learn_app: boolean
enable_marketplace: boolean
enable_social_oauth_login: boolean
enable_step_by_step_tour: boolean
enable_trial_app: boolean
is_allow_create_workspace: boolean
is_allow_register: boolean

View File

@ -851,7 +851,6 @@ export const zSystemFeatureModel = z.object({
enable_learn_app: z.boolean().default(true),
enable_marketplace: z.boolean().default(false),
enable_social_oauth_login: z.boolean().default(false),
enable_step_by_step_tour: z.boolean().default(false),
enable_trial_app: z.boolean().default(false),
is_allow_create_workspace: z.boolean().default(false),
is_allow_register: z.boolean().default(false),

View File

@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 282 B

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-public",
"lastModified": 1782943615,
"lastModified": 1782215796,
"icons": {
"agent-building-blocks": {
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"

View File

@ -1,6 +1,6 @@
{
"prefix": "custom-vender",
"lastModified": 1782516559,
"lastModified": 1782365697,
"icons": {
"agent-v2-access-point": {
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
@ -451,9 +451,6 @@
"width": 12,
"height": 12
},
"line-education-lesson-open-01": {
"body": "<g fill=\"none\"><path d=\"M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667\" stroke=\"currentColor\" stroke-width=\"1.33333\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
},
"line-files-copy": {
"body": "<g fill=\"none\"><path d=\"M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
},

View File

@ -1,7 +1,7 @@
{
"prefix": "custom-vender",
"name": "Dify Custom Vender",
"total": 331,
"total": 330,
"version": "0.0.0-private",
"author": {
"name": "LangGenius, Inc.",

View File

@ -31,8 +31,32 @@ vi.mock('@/context/app-context', () => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}),
useSelector: (selector: (state: {
isCurrentWorkspaceDatasetOperator: boolean
isLoadingCurrentWorkspace: boolean
isLoadingWorkspacePermissionKeys: boolean
userProfile: { id: string }
workspacePermissionKeys: string[]
}) => unknown) => selector({
isCurrentWorkspaceDatasetOperator: false,
isLoadingCurrentWorkspace: false,
isLoadingWorkspacePermissionKeys: false,
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}), () => ({
isRbacEnabled: mockIsRbacEnabled,
}))
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: undefined,

View File

@ -1,19 +1,21 @@
'use client'
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import type { getDatasetACLCapabilities } from '@/utils/permission'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import {
useDatasetACLCapabilities,
useDatasetRbacEnabled,
useDatasetWorkspaceAccess,
} from '@/app/components/datasets/hooks/use-dataset-access'
import DatasetDetailContext from '@/context/dataset-detail'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePathname, useRouter } from '@/next/navigation'
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
import { getDatasetACLCapabilities } from '@/utils/permission'
type IAppDetailLayoutProps = {
children: React.ReactNode
@ -58,23 +60,15 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const {
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
userProfile,
workspacePermissionKeys,
} = useAppContext()
const isRbacEnabled = systemFeatures.rbac_enabled
} = useDatasetWorkspaceAccess()
const isRbacEnabled = useDatasetRbacEnabled()
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const shouldRedirect = shouldRedirectToDatasetList(error)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, {
currentUserId: userProfile?.id,
resourceMaintainer: datasetRes?.maintainer,
workspacePermissionKeys,
isRbacEnabled,
}), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys])
const datasetACLCapabilities = useDatasetACLCapabilities(datasetRes, { isRbacEnabled })
const isAccessConfigPath = pathname.endsWith('/access-config')
const isHitTestingPath = pathname.endsWith('/hitTesting')
const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath

View File

@ -20,6 +20,12 @@ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: AppContextMock) => unknown) => selector(mockUseAppContext()),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockUseAppContext())
})
vi.mock('@/context/external-api-panel-context', () => ({
ExternalApiPanelProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
}))

View File

@ -2,11 +2,10 @@
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
import { usePathname, useRouter } from '@/next/navigation'
import { hasPermission } from '@/utils/permission'
const isDatasetCreatePath = (pathname: string) => {
return pathname === '/datasets/create'
@ -21,15 +20,14 @@ const isDatasetExternalConnectPath = (pathname: string) => {
}
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
const currentWorkspaceId = useAppContextSelector(state => state.currentWorkspace.id)
const isLoadingCurrentWorkspace = useAppContextSelector(state => state.isLoadingCurrentWorkspace)
const isLoadingWorkspacePermissionKeys = useAppContextSelector(state => state.isLoadingWorkspacePermissionKeys)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const {
currentWorkspaceId,
isLoadingAccess,
canCreateDataset,
canConnectExternalDataset,
} = useDatasetWorkspaceAccess()
const router = useRouter()
const pathname = usePathname()
const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const shouldRedirectToDatasets = !isLoadingAccess
&& !!currentWorkspaceId
&& ((isDatasetCreatePath(pathname) && !canCreateDataset)

View File

@ -3,7 +3,6 @@ import type { App } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { AccessMode } from '@/models/access-control'
import * as appsService from '@/service/apps'
import * as exploreService from '@/service/explore'
@ -277,24 +276,18 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
children,
className,
popupClassName,
popupProps,
positionerProps,
}: {
children: React.ReactNode
className?: string
popupClassName?: string
popupProps?: React.HTMLAttributes<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen)
return null
return (
<div data-testid="dropdown-menu-positioner" {...positionerProps}>
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')} {...popupProps}>
{children}
</div>
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')}>
{children}
</div>
)
},
@ -1743,55 +1736,6 @@ describe('AppCard', () => {
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
})
it('should render the tour-controlled operations menu as presentation only', async () => {
render(
<AppCard
app={mockApp}
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
stepByStepTourActionMenuOpen
/>,
)
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'app.editApp', hidden: true })).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu-positioner'))
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu)
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
})
it('should keep the tour-controlled operations menu open when its trigger is clicked', async () => {
render(
<AppCard
app={mockApp}
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
stepByStepTourActionMenuOpen
/>,
)
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
expect(screen.getByText('app.editApp')).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
})
it('should keep tour-controlled operations menu items from running actions', async () => {
render(
<AppCard
app={mockApp}
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
stepByStepTourActionMenuOpen
/>,
)
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete', hidden: true }))
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
})
})
describe('Open in Explore - No App Found', () => {

View File

@ -30,14 +30,6 @@ describe('Empty', () => {
render(<Empty message="app.newApp.noAppsFound" />)
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
})
it('should expose the step-by-step tour target on the centered empty content', () => {
render(<Empty message={defaultMessage} stepByStepTourTarget="studio-empty-target" />)
const target = screen.getByText(defaultMessage).closest('[data-step-by-step-tour-target]')
expect(target).toHaveAttribute('data-step-by-step-tour-target', 'studio-empty-target')
expect(target).toHaveClass('flex', 'flex-col', 'items-center', 'justify-center', 'gap-3')
})
})
describe('Styling', () => {

View File

@ -1,10 +1,8 @@
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { StepByStepTourAccountState } from '@/app/components/step-by-step-tour/types'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { AppModeEnum } from '@/types/app'
@ -30,41 +28,6 @@ const mockAppStarredListQueryOptions = vi.hoisted(() => vi.fn((options: unknown)
const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({
onlineUsersMap: {},
})))
const mockStepByStepTour = vi.hoisted(() => {
const createState = (
overrides: Partial<StepByStepTourAccountState> = {},
): StepByStepTourAccountState => ({
activeGuideGroup: undefined,
activeGuideIndex: undefined,
activeGuideIndexes: undefined,
activeTaskId: undefined,
completedTaskIds: ['home'],
eligible: true,
firstWorkspaceId: 'workspace-1',
manuallyDisabledWorkspaceIds: [],
manuallyEnabledWorkspaceIds: ['workspace-1'],
minimized: true,
skipped: false,
updatedAt: null,
...overrides,
})
let state = createState()
return {
get state() {
return state
},
reset() {
state = createState()
},
setState(nextState: StepByStepTourAccountState) {
state = nextState
},
setTestState(overrides: Partial<StepByStepTourAccountState> = {}) {
state = createState(overrides)
},
}
})
const mockReplace = vi.fn()
const mockRouter = { replace: mockReplace }
@ -105,13 +68,6 @@ vi.mock('@/service/client', () => ({
},
}))
vi.mock('@/app/components/step-by-step-tour/storage', () => ({
useSetStepByStepTourAccountState: () => (nextState: StepByStepTourAccountState) => {
mockStepByStepTour.setState(nextState)
},
useStepByStepTourAccountStateValue: () => mockStepByStepTour.state,
}))
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
let mockWorkspacePermissionKeys = ['app.create_and_management']
vi.mock('@/context/app-context', () => ({
@ -325,43 +281,10 @@ vi.mock('@/next/dynamic', () => ({
}))
vi.mock('../app-card', () => ({
AppCard: ({
app,
stepByStepTourActionMenuOpen,
stepByStepTourActionMenuHighlightPart,
stepByStepTourCardTarget,
stepByStepTourCardHighlightPart,
}: {
app: { id: string, name: string }
stepByStepTourActionMenuOpen?: boolean
stepByStepTourActionMenuHighlightPart?: string
stepByStepTourCardTarget?: string
stepByStepTourCardHighlightPart?: string
}) => {
return React.createElement(
'div',
{
'data-testid': `app-card-${app.id}`,
'data-step-by-step-tour-target': stepByStepTourCardTarget,
'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart,
'role': 'article',
},
app.name,
React.createElement('button', {
'data-testid': `app-card-action-bar-${app.id}`,
'data-step-by-step-tour-highlight-part': stepByStepTourActionMenuHighlightPart,
'data-step-by-step-tour-menu-open': String(Boolean(stepByStepTourActionMenuOpen)),
'type': 'button',
}),
)
AppCard: ({ app }: { app: { id: string, name: string } }) => {
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
},
AppCardActionBar: ({
app,
onRefresh,
}: {
app: { id: string }
onRefresh?: () => void
}) => {
AppCardActionBar: ({ app, onRefresh }: { app: { id: string }, onRefresh?: () => void }) => {
return React.createElement('button', {
'data-testid': `app-card-action-bar-${app.id}`,
'type': 'button',
@ -374,12 +297,8 @@ vi.mock('../app-card', () => ({
}))
vi.mock('../empty', () => ({
default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => {
return React.createElement('div', {
'data-testid': 'empty-state',
'data-step-by-step-tour-target': stepByStepTourTarget,
'role': 'status',
}, 'No apps found')
default: () => {
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
},
}))
@ -442,22 +361,9 @@ const openAppSortSelect = async (user = userEvent.setup()) => {
return user
}
const setActiveStudioStepByStepTour = (
activeGuideIndex: number,
activeGuideGroup: 'studioWithApps' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps' | undefined = 'studioWithApps',
) => {
mockStepByStepTour.setTestState({
activeTaskId: 'studio',
activeGuideGroup,
activeGuideIndex,
minimized: true,
})
}
describe('List', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStepByStepTour.reset()
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
mockWorkspacePermissionKeys = ['app.create_and_management']
mockDragging = false
@ -523,52 +429,6 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
})
it('should open the create menu for the Studio with-apps tour create guide', async () => {
setActiveStudioStepByStepTour(0)
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate)
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
expect(screen.getByText(/app\.importDSL/)).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank', hidden: true })).toBeInTheDocument()
const createMenuHighlightPart = document.body.querySelector('[data-step-by-step-tour-highlight-part]')
expect(createMenuHighlightPart)
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu)
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
})
it('should allow closing the tour-opened Studio create menu from its trigger', async () => {
setActiveStudioStepByStepTour(0)
renderList()
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
await waitFor(() => {
expect(screen.queryByText('app.newApp.startFromBlank')).not.toBeInTheDocument()
})
})
it('should open the create menu before the Studio with-apps guide group is persisted', async () => {
setActiveStudioStepByStepTour(0, undefined)
renderList()
expect(screen.getByRole('button', { name: 'common.operation.create' }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate)
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank', hidden: true })).toBeInTheDocument()
const createMenuHighlightPart = document.body.querySelector('[data-step-by-step-tour-highlight-part]')
expect(createMenuHighlightPart)
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu)
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
})
it('should render filters and search before the right aligned actions', () => {
renderList()
@ -652,115 +512,6 @@ describe('List', () => {
expect(mockRefetchStarredAppList).toHaveBeenCalledTimes(1)
})
it('should expose the first workspace app card and open its action menu for the Studio with-apps tour manage guide', () => {
setActiveStudioStepByStepTour(1)
mockStarredAppData = {
data: [{
id: 'starred-app-1',
name: 'Starred App',
description: 'Starred description',
mode: AppModeEnum.CHAT,
icon: '⭐',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: null,
tags: [],
author_name: 'Author 1',
created_at: 1704067200,
updated_at: 1704153600,
}],
total: 1,
page: 1,
limit: 100,
has_more: false,
}
renderList()
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
const starredCard = screen.getByRole('link', { name: /Starred App/ })
const starredActionBar = screen.getByTestId('app-card-action-bar-starred-app-1')
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard)
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu)
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'true')
expect(screen.queryByRole('menuitem', { name: 'app.newApp.startFromBlank' })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: 'app.newApp.startFromTemplate' })).not.toBeInTheDocument()
expect(starredCard).not.toHaveAttribute('data-step-by-step-tour-target')
expect(starredActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
})
it('should highlight the first starred app row for the Studio no-create with-apps tour', () => {
mockWorkspacePermissionKeys = []
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
mockStarredAppData = {
data: [{
id: 'starred-app-1',
name: 'Starred App',
description: 'Starred description',
mode: AppModeEnum.CHAT,
icon: '⭐',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: null,
tags: [],
author_name: 'Author 1',
created_at: 1704067200,
updated_at: 1704153600,
}],
total: 1,
page: 1,
limit: 100,
has_more: false,
}
renderList()
const starredCard = screen.getByRole('link', { name: /Starred App/ })
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
expect(starredCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard)
expect(starredCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-target')
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
})
it('should highlight the first all-apps row for the Studio no-create with-apps tour when there are no starred apps', () => {
mockWorkspacePermissionKeys = []
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
renderList()
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
const secondWorkspaceCard = screen.getByTestId('app-card-app-2')
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard)
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
expect(secondWorkspaceCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
expect(firstWorkspaceActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
})
it('should expose the regular empty state for the Studio no-create empty tour', () => {
mockWorkspacePermissionKeys = []
mockAppData = { pages: [{ data: [], total: 0 }] }
setActiveStudioStepByStepTour(0, 'studioNoCreateEmpty')
renderList()
const target = document.querySelector(getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty))
expect(target).toBeInTheDocument()
expect(target).toBe(screen.getByTestId('empty-state'))
expect(target).not.toHaveClass('absolute', 'top-1/2', 'left-1/2')
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
})
it('should not render new app card in the app grid', () => {
renderList()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
@ -782,14 +533,6 @@ describe('List', () => {
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /app\.newApp\.startFromTemplate/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate)
expect(screen.getByRole('button', { name: /app\.newApp\.startFromBlank/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank)
expect(screen.getByRole('button', { name: /app\.importDSL/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL)
expect(screen.getByText('app.firstEmpty.learnDifyTitle').closest('[data-step-by-step-tour-target]'))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify)
})
it('should lay out first empty state placeholder cards with auto-fill grid columns', () => {

View File

@ -39,7 +39,6 @@ import AppIcon from '@/app/components/base/app-icon'
import StarIcon from '@/app/components/base/icons/src/vender/Star'
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -101,10 +100,6 @@ type AppCardProps = {
onlineUsers?: WorkflowOnlineUser[]
onRefresh?: () => void
onOpenTagManagement?: () => void
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
stepByStepTourCardHighlightPart?: string
stepByStepTourActionMenuHighlightPart?: string
}
type AppAccessModeIconProps = {
@ -313,10 +308,7 @@ type AppCardActionBarProps = {
onRefresh?: () => void
}
export function AppCardActionBar({
app,
onRefresh,
}: AppCardActionBarProps) {
export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
@ -749,16 +741,7 @@ export function AppCardActionBar({
)
}
export function AppCard({
app,
onlineUsers = [],
onRefresh,
onOpenTagManagement = () => {},
stepByStepTourActionMenuOpen = false,
stepByStepTourCardTarget,
stepByStepTourCardHighlightPart,
stepByStepTourActionMenuHighlightPart,
}: AppCardProps) {
export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
@ -773,13 +756,7 @@ export function AppCard({
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
const [showAccessControl, setShowAccessControl] = useState(false)
const operationsMenu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled: false,
controlledOpen: stepByStepTourActionMenuOpen,
})
const closeOperationsMenu = operationsMenu.close
const isOperationsMenuOpen = operationsMenu.open
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
@ -830,44 +807,44 @@ export function AppCard({
}
function handleShowEditModal() {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowEditModal(true)
})
}
function handleShowDuplicateModal() {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowDuplicateModal(true)
})
}
function handleShowSwitchModal() {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowSwitchModal(true)
})
}
function handleShowDeleteConfirm() {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowConfirmDelete(true)
})
}
function handleShowAccessControl() {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
queueMicrotask(() => {
setShowAccessControl(true)
})
}
const handleOpenAccessConfig = useCallback(() => {
closeOperationsMenu()
setIsOperationsMenuOpen(false)
push(`/app/${app.id}/access-config`)
}, [app.id, closeOperationsMenu, push])
}, [app.id, push])
const onEdit: CreateAppModalProps['onConfirm'] = async ({
name,
@ -1053,7 +1030,6 @@ export function AppCard({
const starActionLabel = app.is_starred
? t('studio.unstarApp', { ns: 'app' })
: t('studio.starApp', { ns: 'app' })
const operationsMenuOpen = isOperationsMenuOpen
const showPreviewOnlyAccessWarning = useCallback(() => {
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
}, [t])
@ -1124,28 +1100,24 @@ export function AppCard({
>
{isPreviewOnly
? (
<div
<article
role="button"
tabIndex={0}
aria-disabled="true"
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={appCardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
{appCardContent}
</div>
</article>
)
: (
<Link
href={appHref}
aria-labelledby={appNameId}
aria-describedby={app.description ? appDescriptionId : undefined}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={appCardClassName}
>
{appCardContent}
@ -1153,6 +1125,10 @@ export function AppCard({
)}
<div
className="absolute top-[104px] right-3 left-3 flex h-[26px] min-w-0 items-start"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<AppCardTags
appId={app.id}
@ -1167,7 +1143,7 @@ export function AppCard({
<div
className={cn(
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
operationsMenuOpen
isOperationsMenuOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
)}
@ -1195,12 +1171,12 @@ export function AppCard({
<TooltipContent>{starActionLabel}</TooltipContent>
</Tooltip>
{shouldShowOperationsMenu && (
<DropdownMenu modal={false} open={operationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
<DropdownMenuTrigger
aria-label={t('operation.more', { ns: 'common' })}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
operationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
@ -1213,12 +1189,7 @@ export function AppCard({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: operationsMenu.controlled,
highlightPart: operationsMenu.controlled ? stepByStepTourActionMenuHighlightPart : undefined,
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
popupClassName: operationsMenuWidthClassName,
})}
popupClassName={operationsMenuWidthClassName}
>
{systemFeatures.webapp_auth.enabled
? (

View File

@ -7,7 +7,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import Link from '@/next/link'
import { AppSortFilter } from './app-sort-filter'
@ -33,9 +32,6 @@ type AppListHeaderFiltersProps = {
onImportDSL: () => void
onOpenTagManagement: () => void
showCreateButton: boolean
stepByStepTourCreateMenuOpen?: boolean
stepByStepTourCreateMenuTarget?: string
stepByStepTourCreateMenuHighlightPart?: string
}
export function AppListHeaderFilters({
@ -54,14 +50,8 @@ export function AppListHeaderFilters({
onImportDSL,
onOpenTagManagement,
showCreateButton,
stepByStepTourCreateMenuOpen,
stepByStepTourCreateMenuTarget,
stepByStepTourCreateMenuHighlightPart,
}: AppListHeaderFiltersProps) {
const { t } = useTranslation()
const createMenu = useStepByStepTourControlledDropdown({
controlledOpen: stepByStepTourCreateMenuOpen,
})
return (
<div className="flex flex-wrap items-start justify-between gap-2">
@ -93,11 +83,10 @@ export function AppListHeaderFilters({
{t('studio.viewSnippets', { ns: 'app' })}
</Link>
{showCreateButton && (
<DropdownMenu modal={false} open={createMenu.open} onOpenChange={createMenu.onOpenChange}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={(
<Button
data-step-by-step-tour-target={stepByStepTourCreateMenuTarget}
variant="primary"
size="medium"
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
@ -111,12 +100,7 @@ export function AppListHeaderFilters({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: createMenu.controlled,
highlightPart: createMenu.controlled ? stepByStepTourCreateMenuHighlightPart : undefined,
interactionMode: createMenu.controlled ? 'presentation' : 'interactive',
popupClassName: 'w-70 p-0',
})}
popupClassName="w-70 p-0"
>
<div className="py-1">
<DropdownMenuItem

View File

@ -4,18 +4,12 @@ import FilterEmptyState from '@/app/components/base/filter-empty-state'
type EmptyProps = {
message?: string
stepByStepTourTarget?: string
}
const Empty = ({ message, stepByStepTourTarget }: EmptyProps) => {
const Empty = ({ message }: EmptyProps) => {
const { t } = useTranslation()
return (
<FilterEmptyState
title={message ?? t('filterEmpty.noApps', { ns: 'app' })}
contentDataAttributes={stepByStepTourTarget ? { 'data-step-by-step-tour-target': stepByStepTourTarget } : undefined}
/>
)
return <FilterEmptyState title={message ?? t('filterEmpty.noApps', { ns: 'app' })} />
}
export default React.memo(Empty)

View File

@ -8,7 +8,6 @@ type VisualStyle = 'default' | 'compact' | 'list'
type BaseProps = {
badge?: string
badgeVariant?: 'basic' | 'advanced'
stepByStepTourTarget?: string
description: string
icon: ReactNode
title: string
@ -128,7 +127,7 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
if (props.href) {
return (
<Link href={props.href} className={className} data-step-by-step-tour-target={props.stepByStepTourTarget}>
<Link href={props.href} className={className}>
<ActionCardContent
badge={props.badge}
badgeVariant={props.badgeVariant}
@ -142,7 +141,7 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
}
return (
<button type="button" className={className} data-step-by-step-tour-target={props.stepByStepTourTarget} onClick={props.onClick}>
<button type="button" className={className} onClick={props.onClick}>
<ActionCardContent
badge={props.badge}
badgeVariant={props.badgeVariant}

View File

@ -3,7 +3,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import LearnDify from '@/app/components/explore/learn-dify'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import FirstEmptyActionCard from './action-card'
const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `placeholder-card-${index}`)
@ -14,7 +13,6 @@ type EmptyCreateAction = {
title: string
description: string
onClick: () => void
target: string
}
type Props = {
@ -39,7 +37,6 @@ function FirstEmptyState({
title: t('newApp.startFromTemplate', { ns: 'app' }),
description: t('firstEmpty.templateDescription', { ns: 'app' }),
onClick: onCreateTemplate,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate,
},
{
id: 'blank',
@ -47,7 +44,6 @@ function FirstEmptyState({
title: t('newApp.startFromBlank', { ns: 'app' }),
description: t('firstEmpty.blankDescription', { ns: 'app' }),
onClick: onCreateBlank,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank,
},
{
id: 'dsl',
@ -55,7 +51,6 @@ function FirstEmptyState({
title: t('importDSL', { ns: 'app' }),
description: t('firstEmpty.importDescription', { ns: 'app' }),
onClick: onImportDSL,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL,
},
]
@ -88,7 +83,6 @@ function FirstEmptyState({
description={action.description}
icon={action.icon}
onClick={action.onClick}
stepByStepTourTarget={action.target}
title={action.title}
visualStyle="list"
/>
@ -103,7 +97,6 @@ function FirstEmptyState({
description={actions[2]!.description}
icon={actions[2]!.icon}
onClick={actions[2]!.onClick}
stepByStepTourTarget={actions[2]!.target}
title={actions[2]!.title}
visualStyle="list"
/>
@ -112,15 +105,13 @@ function FirstEmptyState({
</section>
</div>
{showLearnDify && (
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify}>
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
</div>
<LearnDify
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
showDescription
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
/>
)}
</div>
)

View File

@ -7,14 +7,6 @@ import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNeedRefreshAppList } from '@/app/components/apps/storage'
import {
useSetStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
} from '@/app/components/step-by-step-tour/storage'
import {
getStepByStepTourGuides,
STEP_BY_STEP_TOUR_TARGETS,
} from '@/app/components/step-by-step-tour/target-registry'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -38,7 +30,6 @@ import { StarredAppList } from './starred-app-list'
import { StudioListHeader } from './studio-list-header'
const STARRED_APP_LIMIT = 100
const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4
type AppListQuery = NonNullable<GetAppsData['query']>
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
@ -72,10 +63,6 @@ function List({
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList()
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const handleDSLFileDropped = useCallback((file: File) => {
@ -219,24 +206,6 @@ function List({
const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || creatorIDs.length > 0
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const showFirstEmptyState = !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters
const showNoCreateEmptyState = !showSkeleton && !hasAnyApp && !canCreateApp && hasResolvedFirstPage && !hasActiveFilters
const activeStudioGuideGroup = canCreateApp
? showFirstEmptyState
? 'studioEmpty'
: hasAnyApp ? 'studioWithApps' : undefined
: hasAnyApp
? 'studioNoCreateWithApps'
: showNoCreateEmptyState ? 'studioNoCreateEmpty' : undefined
const effectiveActiveStudioGuideGroup = stepByStepTourAccountState.activeGuideGroup ?? activeStudioGuideGroup
const activeStudioGuides = stepByStepTourAccountState.activeTaskId === 'studio' && effectiveActiveStudioGuideGroup
? getStepByStepTourGuides('studio', effectiveActiveStudioGuideGroup)
: []
const activeStudioGuide = activeStudioGuides[stepByStepTourAccountState.activeGuideIndex ?? 0]
const shouldOpenStepByStepTourCreateMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate
const shouldOpenStepByStepTourAppCardActionMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
const shouldHighlightStepByStepTourNoCreateAppRow = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
const shouldHighlightStepByStepTourStarredAppRow = shouldHighlightStepByStepTourNoCreateAppRow && starredApps.length > 0
const shouldHighlightStepByStepTourAllAppsRow = shouldHighlightStepByStepTourNoCreateAppRow && !shouldHighlightStepByStepTourStarredAppRow
const openCreateBlankModal = useCallback(() => {
if (canCreateApp)
setShowNewAppModal(true)
@ -250,30 +219,6 @@ function List({
setShowCreateFromDSLModal(true)
}, [canCreateApp])
useEffect(() => {
if (stepByStepTourAccountState.activeTaskId !== 'studio')
return
if (!hasResolvedFirstPage || showSkeleton || !activeStudioGuideGroup)
return
if (stepByStepTourAccountState.activeGuideGroup === activeStudioGuideGroup)
return
// Sync the active walkthrough branch into the tour storage owner after the
// Studio list data resolves.
// eslint-disable-next-line react/set-state-in-effect
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeGuideGroup: activeStudioGuideGroup,
activeGuideIndex: 0,
})
}, [
activeStudioGuideGroup,
hasResolvedFirstPage,
setStepByStepTourAccountState,
showSkeleton,
stepByStepTourAccountState,
])
return (
<>
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
@ -305,9 +250,6 @@ function List({
onImportDSL={openCreateFromDSLModal}
onOpenTagManagement={() => setShowTagManagementModal(true)}
showCreateButton={canCreateApp}
stepByStepTourCreateMenuOpen={activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate}
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu}
/>
</StudioListHeader>
{showFirstEmptyState
@ -325,9 +267,6 @@ function List({
<StarredAppList
apps={starredApps}
onRefresh={refreshAppLists}
stepByStepTourCardTarget={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard : undefined}
stepByStepTourCardHighlightPart={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard : undefined}
stepByStepTourHighlightedCardCount={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT : 0}
/>
)}
<div className={cn(
@ -338,24 +277,16 @@ function List({
{showSkeleton
? <AppCardSkeleton count={6} />
: hasAnyApp
? apps.map((app, index) => (
? apps.map(app => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refreshAppLists}
onOpenTagManagement={() => setShowTagManagementModal(true)}
stepByStepTourActionMenuOpen={index === 0 ? shouldOpenStepByStepTourAppCardActionMenu : undefined}
stepByStepTourCardTarget={index === 0
? shouldHighlightStepByStepTourAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
: canCreateApp ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard : undefined
: undefined}
stepByStepTourCardHighlightPart={index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightStepByStepTourAllAppsRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard : undefined}
stepByStepTourActionMenuHighlightPart={index === 0 && shouldOpenStepByStepTourAppCardActionMenu ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu : undefined}
/>
))
: <Empty stepByStepTourTarget={showNoCreateEmptyState ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty : undefined} />}
: <Empty />}
{isFetchingNextPage && (
<AppCardSkeleton count={3} />
)}

View File

@ -20,16 +20,9 @@ import { AppCardActionBar } from './app-card'
type StarredAppCardProps = {
app: App
onRefresh?: () => void
stepByStepTourCardTarget?: string
stepByStepTourCardHighlightPart?: string
}
export function StarredAppCard({
app,
onRefresh,
stepByStepTourCardTarget,
stepByStepTourCardHighlightPart,
}: StarredAppCardProps) {
export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) {
const { t } = useTranslation()
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
@ -97,36 +90,24 @@ export function StarredAppCard({
<div className="group relative">
{isPreviewOnly
? (
<div
<article
role="button"
tabIndex={0}
aria-disabled="true"
aria-label={app.name}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
{cardContent}
</div>
</article>
)
: (
<Link
href={href}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
>
<Link href={href} className={cardClassName}>
{cardContent}
</Link>
)}
{!isPreviewOnly && (
<AppCardActionBar
app={app}
onRefresh={onRefresh}
/>
)}
{!isPreviewOnly && <AppCardActionBar app={app} onRefresh={onRefresh} />}
</div>
)
}

View File

@ -8,9 +8,6 @@ import { StarredAppCard } from './starred-app-card'
type StarredAppListProps = {
apps: App[]
onRefresh?: () => void
stepByStepTourCardTarget?: string
stepByStepTourCardHighlightPart?: string
stepByStepTourHighlightedCardCount?: number
}
function SectionDivider({ label }: { label: string }) {
@ -26,9 +23,6 @@ function SectionDivider({ label }: { label: string }) {
export function StarredAppList({
apps,
onRefresh,
stepByStepTourCardTarget,
stepByStepTourCardHighlightPart,
stepByStepTourHighlightedCardCount = 0,
}: StarredAppListProps) {
const { t } = useTranslation()
@ -39,13 +33,11 @@ export function StarredAppList({
<>
<SectionDivider label={t('studio.starred', { ns: 'app' })} />
<div className={APP_LIST_GRID_CLASS_NAME}>
{apps.map((app, index) => (
{apps.map(app => (
<StarredAppCard
key={app.id}
app={app}
onRefresh={onRefresh}
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
stepByStepTourCardHighlightPart={index < stepByStepTourHighlightedCardCount ? stepByStepTourCardHighlightPart : undefined}
/>
))}
</div>

View File

@ -1,14 +1,9 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type DataAttributes = {
[key: `data-${string}`]: string | undefined
}
type FilterEmptyStateProps = {
title: ReactNode
className?: string
contentDataAttributes?: DataAttributes
}
const CARD_COUNT = 16
@ -16,7 +11,6 @@ const CARD_COUNT = 16
const FilterEmptyState = ({
title,
className,
contentDataAttributes,
}: FilterEmptyStateProps) => {
return (
<div className={cn('pointer-events-none absolute inset-0 z-20 grid grid-cols-4 grid-rows-4 gap-3 px-8 pt-2', className)}>
@ -25,7 +19,7 @@ const FilterEmptyState = ({
))}
<div className="absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" />
<div className="absolute inset-0 flex items-center justify-center overflow-hidden p-2">
<div {...contentDataAttributes} className="flex flex-col items-center justify-center gap-3">
<div className="flex flex-col items-center justify-center gap-3">
<div className="flex size-14 items-center justify-center rounded-lg">
<div className="flex size-full min-w-px items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1">
<span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" />

View File

@ -79,6 +79,14 @@ vi.mock('@/context/app-context', () => ({
useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
isRbacEnabled: mockIsRbacEnabled,
}))
})
vi.mock('@/app/components/access-rules-editor', () => ({
default: (props: AccessRulesEditorProps) => {
mockAccessRulesEditor.props = props

View File

@ -2,15 +2,16 @@
import type { ResourceOpenScope } from '@/models/access-control'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AccessRulesEditor from '@/app/components/access-rules-editor'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import {
useDatasetACLCapabilities,
useDatasetRbacEnabled,
} from '@/app/components/datasets/hooks/use-dataset-access'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
import {
useDatasetAccessRules,
@ -19,7 +20,6 @@ import {
useUpdateDatasetOpenScope,
useUpdateDatasetUserAccessSettings,
} from '@/service/access-control/use-dataset-access-config'
import { getDatasetACLCapabilities } from '@/utils/permission'
type DatasetAccessConfigPageProps = {
datasetId: string
@ -30,16 +30,8 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
const locale = useLocale()
const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale])
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const canAccessConfig = useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
isRbacEnabled,
}).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, isRbacEnabled, workspacePermissionKeys])
const isRbacEnabled = useDatasetRbacEnabled()
const canAccessConfig = useDatasetACLCapabilities(dataset, { isRbacEnabled }).canAccessConfig
const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig })
const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig })
const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = useUpdateDatasetOpenScope(datasetId)

View File

@ -54,6 +54,16 @@ vi.mock('@/context/app-context', () => ({
},
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: mockCurrentUserId },
workspacePermissionKeys: mockWorkspacePermissionKeys,
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
}))
})
// Mock modal context
const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({

View File

@ -7,16 +7,18 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import {
useDatasetACLCapabilities,
useDatasetWorkspaceAccess,
} from '@/app/components/datasets/hooks/use-dataset-access'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DataSourceProvider } from '@/models/common'
import { DataSourceType } from '@/models/datasets'
import { useRouter } from '@/next/navigation'
import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource'
import { getDatasetACLCapabilities } from '@/utils/permission'
import AppUnavailable from '../../base/app-unavailable'
import { ModelTypeEnum } from '../../header/account-setting/model-provider-page/declarations'
import StepOne from './step-one'
@ -43,15 +45,10 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
const router = useRouter()
const openIntegrationsSetting = useIntegrationsSetting()
const datasetDetail = useDatasetDetailContextWithSelector(state => state.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const isLoadingWorkspacePermissionKeys = useAppContextWithSelector(state => state.isLoadingWorkspacePermissionKeys)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { isLoadingWorkspacePermissionKeys } = useDatasetWorkspaceAccess()
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const canAddDocumentsToDataset = !datasetId || getDatasetACLCapabilities(datasetDetail?.permission_keys, {
currentUserId,
resourceMaintainer: datasetDetail?.maintainer,
workspacePermissionKeys,
}).canUse
const datasetACLCapabilities = useDatasetACLCapabilities(datasetDetail)
const canAddDocumentsToDataset = !datasetId || datasetACLCapabilities.canUse
const shouldRedirectToDocuments = !!datasetId
&& !!datasetDetail
&& !isLoadingWorkspacePermissionKeys

View File

@ -55,6 +55,15 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: 'test-user' },
workspacePermissionKeys: ['dataset.create_and_management'],
}))
})
// Mock document service hooks
const mockInvalidDocumentList = vi.fn()
const mockInvalidDocumentDetail = vi.fn()

View File

@ -42,6 +42,15 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: ['dataset.create_and_management'],
}))
})
vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({
default: () => ({
isShowEditModal: false,

View File

@ -9,13 +9,12 @@ import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-la
import Operations from '@/app/components/datasets/documents/components/operations'
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
import StatusItem from '@/app/components/datasets/documents/status-item'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import useTimestamp from '@/hooks/use-timestamp'
import { DataSourceType } from '@/models/datasets'
import { useRouter, useSearchParams } from '@/next/navigation'
import { formatNumber } from '@/utils/format'
import { getDatasetACLCapabilities } from '@/utils/permission'
import DocumentSourceIcon from './document-source-icon'
import { renderTdValue } from './utils'
@ -62,13 +61,7 @@ const DocumentTableRow = React.memo(({
const searchParams = useSearchParams()
const documentNameId = React.useId()
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys])
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
const isFile = doc.data_source_type === DataSourceType.FILE
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''

View File

@ -6,12 +6,11 @@ import { Pagination } from '@langgenius/dify-ui/pagination'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
import { getDatasetACLCapabilities } from '@/utils/permission'
import BatchAction from '../detail/completed/common/batch-action'
import s from '../style.module.css'
import { DocumentTableRow, SortHeader } from './document-list/components'
@ -61,13 +60,7 @@ const DocumentList = ({
const pageSize = pagination.limit ?? 10
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
const datasetConfig = useDatasetDetailContext(s => s.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetConfig?.permission_keys, {
currentUserId,
resourceMaintainer: datasetConfig?.maintainer,
workspacePermissionKeys,
}), [datasetConfig?.maintainer, datasetConfig?.permission_keys, currentUserId, workspacePermissionKeys])
const datasetACLCapabilities = useDatasetACLCapabilities(datasetConfig)
const chunkingMode = datasetConfig?.doc_form
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
const isQAMode = chunkingMode === ChunkingMode.qa
@ -141,7 +134,7 @@ const DocumentList = ({
<thead className="h-8 border-b border-divider-subtle text-xs/8 font-medium text-text-tertiary uppercase">
<tr>
<td className="w-12">
<div className="flex items-center" onClick={e => e.stopPropagation()}>
<div className="flex items-center" role="presentation" onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
{embeddingAvailable && (
<Checkbox
className="mr-2 shrink-0"

View File

@ -63,6 +63,16 @@ vi.mock('@/context/app-context', () => ({
},
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: mockCurrentUserId },
workspacePermissionKeys: mockWorkspacePermissionKeys,
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
}))
})
vi.mock('@/service/use-billing', () => ({
useCurrentPlanVectorSpace: () => ({
data: {

View File

@ -9,7 +9,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import {
useDatasetACLCapabilities,
useDatasetWorkspaceAccess,
} from '@/app/components/datasets/hooks/use-dataset-access'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContextSelector } from '@/context/provider-context'
import { DatasourceType } from '@/models/pipeline'
@ -17,7 +20,6 @@ import { useRouter } from '@/next/navigation'
import { useCurrentPlanVectorSpace } from '@/service/use-billing'
import { useFileUploadConfig } from '@/service/use-common'
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
import { getDatasetACLCapabilities } from '@/utils/permission'
import { useDataSourceStore } from './data-source/store'
import DataSourceProvider from './data-source/store/provider'
import {
@ -40,15 +42,9 @@ const CreateFormPipeline = () => {
const enableBilling = useProviderContextSelector(state => state.enableBilling)
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const pipelineId = dataset?.pipeline_id
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const isLoadingWorkspacePermissionKeys = useAppContextWithSelector(state => state.isLoadingWorkspacePermissionKeys)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { isLoadingWorkspacePermissionKeys } = useDatasetWorkspaceAccess()
const dataSourceStore = useDataSourceStore()
const canAddDocumentsToDataset = getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}).canUse
const canAddDocumentsToDataset = useDatasetACLCapabilities(dataset).canUse
const shouldRedirectToDocuments = !!dataset
&& !isLoadingWorkspacePermissionKeys
&& !canAddDocumentsToDataset

View File

@ -10,8 +10,8 @@ import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import FloatRightContainer from '@/app/components/base/float-right-container'
import Loading from '@/app/components/base/loading'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import Metadata from '@/app/components/datasets/metadata/metadata-document'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ChunkingMode, DisplayStatusList } from '@/models/datasets'
@ -20,7 +20,6 @@ import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { segmentImportStatus } from '@/types/dataset'
import { getDatasetACLCapabilities } from '@/utils/permission'
import Operations from '../components/operations'
import StatusItem from '../status-item'
import BatchModal from './batch-modal'
@ -49,17 +48,8 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
const isMobile = media === MediaType.mobile
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const embeddingAvailable = !!dataset?.embedding_available
const datasetACLCapabilities = useMemo(
() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}),
[dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys],
)
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
const canEditDocument = datasetACLCapabilities.canEdit
const [showMetadata, setShowMetadata] = useState(!isMobile)
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import { useCallback } from 'react'
import Loading from '@/app/components/base/loading'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
@ -10,7 +10,6 @@ import { useRouter } from '@/next/navigation'
import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
import { getDatasetACLCapabilities } from '@/utils/permission'
import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata'
import DocumentsHeader from './components/documents-header'
import EmptyElement from './components/empty-element'
@ -31,14 +30,8 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
const isFreePlan = plan.type === 'sandbox'
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const embeddingAvailable = !!dataset?.embedding_available
const datasetACLCapabilities = getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
})
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
// Use custom hook for page state management
const {

View File

@ -4,12 +4,11 @@ import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from '@/next/link'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { getDatasetACLCapabilities } from '@/utils/permission'
type CardProps = {
apiEnabled: boolean
@ -22,19 +21,10 @@ const Card = ({
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const datasetACLCapabilities = React.useMemo(
() => getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
resourceMaintainer: dataset?.maintainer,
workspacePermissionKeys,
}),
[dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys],
)
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
const canManageApiAccess = datasetACLCapabilities.canEdit
const apiReferenceUrl = useDatasetApiAccessUrl()

View File

@ -15,6 +15,14 @@ vi.mock('@/context/app-context', () => ({
selector({ workspacePermissionKeys: mockWorkspacePermissionKeys }),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
workspacePermissionKeys: mockWorkspacePermissionKeys,
}))
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),

View File

@ -4,9 +4,8 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { hasPermission } from '@/utils/permission'
import Card from './card'
type ServiceApiProps = {
@ -19,8 +18,7 @@ const ServiceApi = ({
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canManageSecretKey = hasPermission(workspacePermissionKeys, 'dataset.api_key.manage')
const { canManageDatasetApiKeys: canManageSecretKey } = useDatasetWorkspaceAccess()
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)

View File

@ -83,6 +83,12 @@ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState)
})
const mockRecordsRefetch = vi.fn()
const mockHitTestingMutateAsync = vi.fn()
const mockExternalHitTestingMutateAsync = vi.fn()

View File

@ -27,7 +27,7 @@ import { useContext } from 'use-context-selector'
import FloatRightContainer from '@/app/components/base/float-right-container'
import Loading from '@/app/components/base/loading'
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useDatasetTestingRecords } from '@/service/knowledge/use-dataset'
@ -35,7 +35,6 @@ import {
useExternalKnowledgeBaseHitTesting,
useHitTesting,
} from '@/service/knowledge/use-hit-testing'
import { getDatasetACLCapabilities } from '@/utils/permission'
import { CardSkelton } from '../documents/detail/completed/skeleton/general-list-skeleton'
import EmptyRecords from './components/empty-records'
import QueryInput from './components/query-input'
@ -63,13 +62,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
const [currPage, setCurrPage] = useState<number>(0)
const { dataset: currentDataset } = useContext(DatasetDetailContext)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const canRunRetrievalRecall = React.useMemo(() => getDatasetACLCapabilities(currentDataset?.permission_keys, {
currentUserId,
resourceMaintainer: currentDataset?.maintainer,
workspacePermissionKeys,
}).canRetrievalRecall, [currentDataset?.maintainer, currentDataset?.permission_keys, currentUserId, workspacePermissionKeys])
const canRunRetrievalRecall = useDatasetACLCapabilities(currentDataset).canRetrievalRecall
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 }, { enabled: canRunRetrievalRecall })
const total = recordsRes?.total || 0
@ -133,6 +126,21 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
if (!canRunRetrievalRecall)
return <Loading type="app" />
let rightPanelContent = renderEmptyState()
if (isRetrievalLoading) {
rightPanelContent = (
<div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3">
<CardSkelton />
</div>
)
}
else if (hitResult?.records.length) {
rightPanelContent = renderHitResults(hitResult.records)
}
else if (externalHitResult?.records.length) {
rightPanelContent = renderHitResults(externalHitResult.records)
}
return (
<div className="relative flex size-full gap-x-6 overflow-y-auto pl-6">
<div className="flex min-w-0 flex-1 flex-col py-3">
@ -193,23 +201,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
onClose={hideRightPanel}
>
<div className="flex min-w-0 flex-1 flex-col pt-3">
{isRetrievalLoading
? (
<div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3">
<CardSkelton />
</div>
)
: (
(() => {
if (!hitResult?.records.length && !externalHitResult?.records.length)
return renderEmptyState()
if (hitResult?.records.length)
return renderHitResults(hitResult.records)
return renderHitResults(externalHitResult?.records || [])
})()
)}
{rightPanelContent}
</div>
</FloatRightContainer>
<Drawer

View File

@ -0,0 +1,83 @@
import type { PermissionKey } from '@/models/access-control'
import { getDatasetACLCapabilities, hasPermission } from '@/utils/permission'
type DatasetAccessMockState = {
userProfile?: {
id?: string
name?: string
email?: string
avatar?: string
avatar_url?: string
is_password_set?: boolean
} | null
currentWorkspace?: {
id?: string
} | null
isCurrentWorkspaceOwner?: boolean
isLoadingCurrentWorkspace?: boolean
isLoadingWorkspacePermissionKeys?: boolean
workspacePermissionKeys?: string[]
}
type DatasetAccessMockOptions = {
isRbacEnabled?: boolean
}
type DatasetAccessResource = {
maintainer?: string | null
permission_keys?: readonly PermissionKey[] | null
}
const defaultUserProfile = {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
}
export const createDatasetAccessHookMock = (
getState: () => DatasetAccessMockState,
getOptions: () => DatasetAccessMockOptions = () => ({}),
) => {
const getUserProfile = () => ({
...defaultUserProfile,
...getState().userProfile,
})
const getWorkspacePermissionKeys = () => getState().workspacePermissionKeys ?? []
return {
useDatasetRbacEnabled: () => getOptions().isRbacEnabled ?? true,
useDatasetCurrentUser: getUserProfile,
useDatasetWorkspaceAccess: () => {
const state = getState()
const workspacePermissionKeys = getWorkspacePermissionKeys()
const isLoadingCurrentWorkspace = state.isLoadingCurrentWorkspace ?? false
const isLoadingWorkspacePermissionKeys = state.isLoadingWorkspacePermissionKeys ?? false
return {
currentWorkspaceId: state.currentWorkspace?.id ?? 'workspace-1',
isCurrentWorkspaceOwner: state.isCurrentWorkspaceOwner ?? false,
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
isLoadingAccess: isLoadingCurrentWorkspace || isLoadingWorkspacePermissionKeys,
workspacePermissionKeys,
canCreateDataset: hasPermission(workspacePermissionKeys, 'dataset.create_and_management'),
canConnectExternalDataset: hasPermission(workspacePermissionKeys, 'dataset.external.connect'),
canManageDatasetTags: hasPermission(workspacePermissionKeys, 'dataset.tag.manage'),
canManageDatasetApiKeys: hasPermission(workspacePermissionKeys, 'dataset.api_key.manage'),
}
},
useDatasetACLCapabilities: (
resource: DatasetAccessResource | null | undefined,
options?: { isRbacEnabled?: boolean },
) => getDatasetACLCapabilities(resource?.permission_keys, {
currentUserId: getUserProfile().id,
resourceMaintainer: resource?.maintainer,
workspacePermissionKeys: getWorkspacePermissionKeys(),
isRbacEnabled: options?.isRbacEnabled,
}),
}
}

View File

@ -0,0 +1,102 @@
import { renderHook } from '@testing-library/react'
import {
currentWorkspaceAtom,
currentWorkspaceLoadingAtom,
systemFeaturesAtom,
userProfileAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import { DatasetACLPermission } from '@/utils/permission'
import {
useDatasetACLCapabilities,
useDatasetCurrentUser,
useDatasetRbacEnabled,
useDatasetWorkspaceAccess,
} from '../use-dataset-access'
const mockAtomValues = vi.hoisted(() => new Map<unknown, unknown>())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: (atom: unknown) => mockAtomValues.get(atom),
}
})
const setDatasetAccessAtomValues = () => {
mockAtomValues.clear()
mockAtomValues.set(systemFeaturesAtom, { rbac_enabled: true })
mockAtomValues.set(userProfileAtom, {
id: 'user-1',
name: 'User 1',
email: 'user1@example.com',
})
mockAtomValues.set(currentWorkspaceAtom, { id: 'workspace-1' })
mockAtomValues.set(workspaceRoleFlagsAtom, { isCurrentWorkspaceOwner: true })
mockAtomValues.set(currentWorkspaceLoadingAtom, false)
mockAtomValues.set(workspacePermissionKeysLoadingAtom, false)
mockAtomValues.set(workspacePermissionKeysAtom, [
'dataset.create_and_management',
'dataset.external.connect',
'dataset.tag.manage',
'dataset.api_key.manage',
])
}
describe('useDatasetAccess hooks', () => {
beforeEach(() => {
setDatasetAccessAtomValues()
})
it('should read RBAC status from the system features atom', () => {
mockAtomValues.set(systemFeaturesAtom, { rbac_enabled: false })
const { result } = renderHook(() => useDatasetRbacEnabled())
expect(result.current).toBe(false)
})
it('should expose workspace access derived from state atoms', () => {
mockAtomValues.set(workspacePermissionKeysLoadingAtom, true)
const { result } = renderHook(() => useDatasetWorkspaceAccess())
expect(result.current).toMatchObject({
currentWorkspaceId: 'workspace-1',
isCurrentWorkspaceOwner: true,
isLoadingCurrentWorkspace: false,
isLoadingWorkspacePermissionKeys: true,
isLoadingAccess: true,
canCreateDataset: true,
canConnectExternalDataset: true,
canManageDatasetTags: true,
canManageDatasetApiKeys: true,
})
})
it('should expose the current user from the user profile atom', () => {
const { result } = renderHook(() => useDatasetCurrentUser())
expect(result.current).toMatchObject({
id: 'user-1',
email: 'user1@example.com',
})
})
it('should compute dataset ACL capabilities with current user and workspace permissions', () => {
const { result } = renderHook(() => useDatasetACLCapabilities({
maintainer: 'user-1',
permission_keys: [DatasetACLPermission.Readonly],
}, {
isRbacEnabled: true,
}))
expect(result.current.canReadonly).toBe(true)
expect(result.current.canEdit).toBe(true)
expect(result.current.canAccessConfig).toBe(true)
})
})

View File

@ -0,0 +1,83 @@
'use client'
import type { PermissionKey } from '@/models/access-control'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import {
currentWorkspaceAtom,
currentWorkspaceLoadingAtom,
systemFeaturesAtom,
userProfileAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import { getDatasetACLCapabilities, hasPermission } from '@/utils/permission'
type DatasetAccessResource = {
maintainer?: string | null
permission_keys?: readonly PermissionKey[] | null
}
export const useDatasetRbacEnabled = () => {
const systemFeatures = useAtomValue(systemFeaturesAtom)
return systemFeatures.rbac_enabled
}
const useDatasetPermissionContext = () => {
const currentUser = useAtomValue(userProfileAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
return {
currentUserId: currentUser.id,
workspacePermissionKeys,
}
}
export const useDatasetWorkspaceAccess = () => {
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const canManageDatasetTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
const canManageDatasetApiKeys = hasPermission(workspacePermissionKeys, 'dataset.api_key.manage')
return {
currentWorkspaceId: currentWorkspace.id,
isCurrentWorkspaceOwner: roleFlags.isCurrentWorkspaceOwner,
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
isLoadingAccess: isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys,
workspacePermissionKeys,
canCreateDataset,
canConnectExternalDataset,
canManageDatasetTags,
canManageDatasetApiKeys,
}
}
export const useDatasetCurrentUser = () => {
return useAtomValue(userProfileAtom)
}
export const useDatasetACLCapabilities = (
resource: DatasetAccessResource | null | undefined,
options?: { isRbacEnabled?: boolean },
) => {
const {
currentUserId,
workspacePermissionKeys,
} = useDatasetPermissionContext()
return useMemo(() => getDatasetACLCapabilities(resource?.permission_keys, {
currentUserId,
resourceMaintainer: resource?.maintainer,
workspacePermissionKeys,
isRbacEnabled: options?.isRbacEnabled,
}), [currentUserId, options?.isRbacEnabled, resource?.maintainer, resource?.permission_keys, workspacePermissionKeys])
}

View File

@ -3,7 +3,6 @@ import type { useDatasetList } from '@/service/knowledge/use-dataset'
import { render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from '../datasets'
@ -70,22 +69,9 @@ vi.mock('../../rename-modal', () => ({
}))
vi.mock('../dataset-card', () => ({
default: ({
dataset,
stepByStepTourActionMenuHighlightPart,
stepByStepTourActionMenuOpen,
stepByStepTourCardTarget,
}: {
dataset: DataSet
stepByStepTourActionMenuHighlightPart?: string
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
}) => (
default: ({ dataset }: { dataset: DataSet }) => (
<article data-testid={`dataset-card-${dataset.id}`}>
{dataset.name}
<span data-testid={`dataset-card-target-${dataset.id}`}>{stepByStepTourCardTarget}</span>
<span data-testid={`dataset-card-menu-open-${dataset.id}`}>{String(stepByStepTourActionMenuOpen)}</span>
<span data-testid={`dataset-card-highlight-${dataset.id}`}>{stepByStepTourActionMenuHighlightPart}</span>
</article>
),
}))
@ -206,26 +192,6 @@ describe('Datasets', () => {
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
})
it('should pass step-by-step tour targets to the first dataset card only', () => {
render((
<Datasets
{...defaultProps}
stepByStepTourActionMenuOpen
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
/>
))
expect(screen.getByTestId('dataset-card-target-dataset-1'))
.toHaveTextContent(STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard)
expect(screen.getByTestId('dataset-card-menu-open-dataset-1')).toHaveTextContent('true')
expect(screen.getByTestId('dataset-card-highlight-dataset-1'))
.toHaveTextContent(STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu)
expect(screen.getByTestId('dataset-card-target-dataset-2')).toBeEmptyDOMElement()
expect(screen.getByTestId('dataset-card-menu-open-dataset-2')).toHaveTextContent('undefined')
expect(screen.getByTestId('dataset-card-highlight-dataset-2')).toBeEmptyDOMElement()
})
it('should render empty element when there are no datasets', () => {
render(
<Datasets

View File

@ -1,30 +1,17 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { render, screen } from '@testing-library/react'
import DatasetListHeader from '../header'
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" className={className} {...props}>{children}</button>
Button: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<button type="button" className={className}>{children}</button>
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({
children,
popupProps,
positionerProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => (
<div {...positionerProps}>
<div role="menu" {...popupProps}>{children}</div>
</div>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
<button type="button" role="menuitem" className={className} onClick={onClick}>{children}</button>
<button type="button" className={className} onClick={onClick}>{children}</button>
),
DropdownMenuSeparator: ({ className }: { className?: string }) => <hr data-testid="create-menu-separator" className={className} />,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => render,
@ -68,9 +55,9 @@ describe('DatasetListHeader', () => {
it('uses the updated create menu labels and pipeline icon', () => {
render(<DatasetListHeader {...defaultProps} />)
expect(screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).toBeInTheDocument()
const menuItem = screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ })
const menuItem = screen.getByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ })
expect(menuItem.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
})
@ -78,9 +65,9 @@ describe('DatasetListHeader', () => {
it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => {
render(<DatasetListHeader {...defaultProps} canCreateDataset={false} />)
expect(screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument()
expect(screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: /dataset\.connectDataset/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /dataset\.connectDataset/ })).toBeInTheDocument()
})
it('should hide external API panel entry when dataset.external.connect is unavailable', () => {
@ -100,38 +87,4 @@ describe('DatasetListHeader', () => {
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
})
it('exposes step-by-step tour targets for the create menu walkthrough', () => {
render((
<DatasetListHeader
{...defaultProps}
stepByStepTourCreateMenuOpen
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
/>
))
expect(screen.getByRole('button', { name: /common\.operation\.create/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate)
expect(screen.getByText('dataset.firstEmpty.createTitle')).toBeInTheDocument()
expect(screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true })).toBeInTheDocument()
const createMenuHighlightPart = document.body.querySelector(`[data-step-by-step-tour-highlight-part="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}"]`)
expect(createMenuHighlightPart).toBeInTheDocument()
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
})
it('keeps the tour-opened create menu as presentation only', () => {
render((
<DatasetListHeader
{...defaultProps}
stepByStepTourCreateMenuOpen
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
/>
))
fireEvent.click(screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true }))
expect(defaultProps.onCreateDataset).not.toHaveBeenCalled()
})
})

View File

@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
import type { StepByStepTourAccountState } from '@/app/components/step-by-step-tour/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import List from '../index'
@ -9,44 +8,10 @@ const mockReplace = vi.fn()
let mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}
let mockIsCurrentWorkspaceOwner = true
const mockStepByStepTour = vi.hoisted(() => {
const createState = (
overrides: Partial<StepByStepTourAccountState> = {},
): StepByStepTourAccountState => ({
activeGuideGroup: undefined,
activeGuideIndex: undefined,
activeGuideIndexes: undefined,
activeTaskId: undefined,
completedTaskIds: ['home', 'studio'],
eligible: true,
firstWorkspaceId: 'workspace-1',
manuallyDisabledWorkspaceIds: [],
manuallyEnabledWorkspaceIds: ['workspace-1'],
minimized: true,
skipped: false,
updatedAt: null,
...overrides,
})
let state = createState()
return {
get state() {
return state
},
reset() {
state = createState()
},
setState(nextState: StepByStepTourAccountState) {
state = nextState
},
setTestState(overrides: Partial<StepByStepTourAccountState> = {}) {
state = createState(overrides)
},
}
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
@ -54,13 +19,6 @@ vi.mock('@/next/navigation', () => ({
}),
}))
vi.mock('@/app/components/step-by-step-tour/storage', () => ({
useSetStepByStepTourAccountState: () => (nextState: StepByStepTourAccountState) => {
mockStepByStepTour.setState(nextState)
},
useStepByStepTourAccountStateValue: () => mockStepByStepTour.state,
}))
// Mock app context
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
@ -70,6 +28,12 @@ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState)
})
// Mock external api panel context
const mockSetShowExternalApiPanel = vi.fn()
vi.mock('@/context/external-api-panel-context', () => ({
@ -172,11 +136,10 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', ()
describe('List', () => {
beforeEach(async () => {
vi.clearAllMocks()
localStorage.clear()
mockStepByStepTour.reset()
mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}
mockIsCurrentWorkspaceOwner = true
@ -215,6 +178,7 @@ describe('List', () => {
mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management'],
}
@ -327,6 +291,7 @@ describe('List', () => {
mockAppContextState = {
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management'],
}
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
@ -344,63 +309,11 @@ describe('List', () => {
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ })).toHaveAttribute('href', '/datasets/create-from-pipeline')
})
it('should activate the Knowledge empty walkthrough for users with all empty-state permissions', async () => {
mockStepByStepTour.setTestState({
activeTaskId: 'knowledge',
activeGuideIndex: 0,
minimized: true,
})
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [], total: 0 }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<List />)
await waitFor(() => {
const state = mockStepByStepTour.state
expect(state.activeGuideGroup).toBe('knowledgeEmpty')
expect(state.activeGuideIndex).toBe(0)
})
})
it('should not activate the Knowledge empty walkthrough until all three empty-state actions are available', async () => {
mockAppContextState = {
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: true,
workspacePermissionKeys: ['dataset.create_and_management'],
}
mockStepByStepTour.setTestState({
activeTaskId: 'knowledge',
activeGuideIndex: 0,
minimized: true,
})
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
vi.mocked(useDatasetList).mockReturnValue({
data: { pages: [{ data: [], total: 0 }] },
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<List />)
await waitFor(() => {
expect(screen.getByText('dataset.firstEmpty.title')).toBeInTheDocument()
})
const state = mockStepByStepTour.state
expect(state.activeGuideGroup).toBeUndefined()
})
it('should not render first empty state for legacy editors without dataset creation permissions', async () => {
mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: [],
}
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
@ -465,6 +378,7 @@ describe('List', () => {
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
isCurrentWorkspaceEditor: false,
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}),
}))
@ -515,6 +429,12 @@ describe('List', () => {
})
it('should not show ExternalAPIPanel without dataset.external.connect even when panel state is open', async () => {
mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management'],
}
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'admin' },
@ -523,6 +443,7 @@ describe('List', () => {
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management'],
}),
}))
@ -550,6 +471,7 @@ describe('List', () => {
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}),
}))
@ -579,6 +501,12 @@ describe('List', () => {
})
it('should not show include all checkbox when not workspace owner', async () => {
mockAppContextState = {
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}
vi.doMock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: { role: 'editor' },
@ -587,6 +515,7 @@ describe('List', () => {
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
}),
}))

View File

@ -3,7 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetCardFooter from '../components/dataset-card-footer'
@ -54,6 +53,12 @@ vi.mock('@/context/app-context', () => ({
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState)
})
vi.mock('../hooks/use-dataset-card-state', () => ({
useDatasetCardState: () => ({
modalState: {
@ -95,26 +100,15 @@ vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({
<div
data-testid="tag-area"
data-can-bind-or-unbind-tags={String(Boolean(canBindOrUnbindTags))}
role="presentation"
onClick={onClick}
onKeyDown={() => undefined}
/>
),
}))
vi.mock('../components/operations-dropdown', () => ({
default: ({
openAccessConfig,
stepByStepTourHighlightPart,
stepByStepTourOpen,
}: {
openAccessConfig?: () => void
stepByStepTourHighlightPart?: string
stepByStepTourOpen?: boolean
}) => (
<div
data-testid="operations-dropdown"
data-has-open-access-config={typeof openAccessConfig === 'function'}
data-step-by-step-tour-highlight-part={stepByStepTourHighlightPart}
data-step-by-step-tour-open={String(stepByStepTourOpen)}
/>
default: ({ openAccessConfig }: { openAccessConfig?: () => void }) => (
<div data-testid="operations-dropdown" data-has-open-access-config={typeof openAccessConfig === 'function'} />
),
}))
@ -153,27 +147,6 @@ describe('DatasetCard Integration', () => {
}
})
describe('Step-by-step tour targets', () => {
it('should expose card and operations targets for the Knowledge walkthrough', () => {
const dataset = createMockDataset()
const { container } = render(
<DatasetCard
dataset={dataset}
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
stepByStepTourActionMenuOpen
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
/>,
)
expect(container.querySelector(`[data-step-by-step-tour-target="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}"]`))
.toBeInTheDocument()
expect(screen.getByTestId('operations-dropdown'))
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu)
expect(screen.getByTestId('operations-dropdown')).toHaveAttribute('data-step-by-step-tour-open', 'true')
})
})
// Integration tests for Description component
describe('Description', () => {
describe('Rendering', () => {

View File

@ -1,5 +1,5 @@
import type { DataSet } from '@/models/datasets'
import { createEvent, fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { IndexingType } from '@/app/components/datasets/create/step-two'
@ -13,7 +13,6 @@ const mockAppContextState = vi.hoisted(() => ({
}))
let mockIsRbacEnabled = true
const noopKeyboardHandler = () => {}
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
@ -25,6 +24,14 @@ vi.mock('@/context/app-context', () => ({
useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
isRbacEnabled: mockIsRbacEnabled,
}))
})
describe('OperationsDropdown', () => {
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
@ -209,73 +216,6 @@ describe('OperationsDropdown', () => {
expect(onOutsideClick).toHaveBeenCalledTimes(1)
})
it('should prevent the card click default behavior when opening the menu', () => {
render(<OperationsDropdown {...defaultProps} />)
const trigger = screen.getByLabelText('Dataset operations')
const event = createEvent.click(trigger)
fireEvent(trigger, event)
expect(event.defaultPrevented).toBe(true)
})
it('should keep menu item clicks from bubbling to the card while running the item action', async () => {
const detectIsUsedByApp = vi.fn()
const onCardClick = vi.fn()
render(
<div role="button" tabIndex={0} onClick={onCardClick} onKeyDown={noopKeyboardHandler}>
<OperationsDropdown
{...defaultProps}
detectIsUsedByApp={detectIsUsedByApp}
/>
</div>,
)
fireEvent.click(screen.getByLabelText('Dataset operations'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
expect(onCardClick).not.toHaveBeenCalled()
})
it('should keep the tour-opened operations menu open when its trigger is clicked', async () => {
render((
<OperationsDropdown
{...defaultProps}
stepByStepTourHighlightPart="knowledge-card-actions-menu"
stepByStepTourOpen
/>
))
expect(await screen.findByText('common.operation.edit')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Dataset operations'))
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('pointer-events-none')
})
it('should keep tour-opened operations menu items from running actions', async () => {
const detectIsUsedByApp = vi.fn()
render((
<OperationsDropdown
{...defaultProps}
detectIsUsedByApp={detectIsUsedByApp}
stepByStepTourHighlightPart="knowledge-card-actions-menu"
stepByStepTourOpen
/>
))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete', hidden: true }))
expect(detectIsUsedByApp).not.toHaveBeenCalled()
expect(screen.getByRole('menu', { hidden: true })).toBeInTheDocument()
})
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsDropdown {...defaultProps} openRenameModal={openRenameModal} />)

View File

@ -5,12 +5,11 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { getDatasetACLCapabilities } from '@/utils/permission'
import {
useDatasetACLCapabilities,
useDatasetRbacEnabled,
} from '@/app/components/datasets/hooks/use-dataset-access'
import Operations from '../operations'
type OperationsDropdownProps = {
@ -19,8 +18,6 @@ type OperationsDropdownProps = {
handleExportPipeline: (include?: boolean) => void
detectIsUsedByApp: () => void
openAccessConfig: () => void
stepByStepTourHighlightPart?: string
stepByStepTourOpen?: boolean
}
const OperationsDropdown = ({
@ -29,23 +26,10 @@ const OperationsDropdown = ({
handleExportPipeline,
detectIsUsedByApp,
openAccessConfig,
stepByStepTourHighlightPart,
stepByStepTourOpen,
}: OperationsDropdownProps) => {
const menu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled: false,
controlledOpen: stepByStepTourOpen,
})
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isRbacEnabled = systemFeatures.rbac_enabled
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
currentUserId,
resourceMaintainer: dataset.maintainer,
workspacePermissionKeys,
isRbacEnabled,
}), [dataset.maintainer, dataset.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys])
const [open, setOpen] = React.useState(false)
const isRbacEnabled = useDatasetRbacEnabled()
const datasetACLCapabilities = useDatasetACLCapabilities(dataset, { isRbacEnabled })
const canShowOperations = datasetACLCapabilities.canEdit
|| datasetACLCapabilities.canImportExportDSL
|| datasetACLCapabilities.canAccessConfig
@ -58,12 +42,15 @@ const OperationsDropdown = ({
<div
className={cn(
'absolute top-2 right-2 z-5',
menu.open
open
? 'pointer-events-auto visible'
: 'pointer-events-none invisible group-hover:pointer-events-auto group-hover:visible',
)}
role="presentation"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
<DropdownMenu modal={false} open={menu.open} onOpenChange={menu.onOpenChange}>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'inline-flex size-9 cursor-pointer items-center justify-center rounded-[10px] border-[0.5px]',
@ -73,21 +60,12 @@ const OperationsDropdown = ({
'data-popup-open:bg-state-base-hover',
)}
aria-label="Dataset operations"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
}}
>
<span className="i-ri-more-fill size-5 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: menu.controlled,
highlightPart: menu.controlled ? stepByStepTourHighlightPart : undefined,
interactionMode: menu.controlled ? 'presentation' : 'interactive',
popupClassName: 'min-w-[186px]',
})}
popupClassName="min-w-[186px]"
>
<Operations
showEdit={datasetACLCapabilities.canEdit}
@ -98,7 +76,6 @@ const OperationsDropdown = ({
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
onClose={menu.close}
/>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -5,10 +5,13 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import {
useDatasetACLCapabilities,
useDatasetWorkspaceAccess,
} from '@/app/components/datasets/hooks/use-dataset-access'
import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags'
import { useRouter } from '@/next/navigation'
import { getDatasetACLCapabilities, hasOnlyDatasetPreviewPermission, hasPermission } from '@/utils/permission'
import { hasOnlyDatasetPreviewPermission } from '@/utils/permission'
import CornerLabels from './components/corner-labels'
import DatasetCardFooter from './components/dataset-card-footer'
import DatasetCardHeader from './components/dataset-card-header'
@ -23,23 +26,16 @@ type DatasetCardProps = {
dataset: DataSet
onSuccess?: () => void
onOpenTagManagement?: () => void
stepByStepTourActionMenuHighlightPart?: string
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
}
const DatasetCard = ({
dataset,
onSuccess,
onOpenTagManagement = () => {},
stepByStepTourActionMenuHighlightPart,
stepByStepTourActionMenuOpen,
stepByStepTourCardTarget,
}: DatasetCardProps) => {
const { t } = useTranslation()
const { push } = useRouter()
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const { canManageDatasetTags } = useDatasetWorkspaceAccess()
const datasetCard = useDatasetCardController({ dataset, onSuccess })
const {
@ -59,13 +55,8 @@ const DatasetCard = ({
return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
const isPreviewOnly = hasOnlyDatasetPreviewPermission(dataset.permission_keys)
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
currentUserId,
resourceMaintainer: dataset.maintainer,
workspacePermissionKeys,
}), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys])
const canManageAppTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || datasetACLCapabilities.canEdit)
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
const canBindOrUnbindTags = !isPreviewOnly && (canManageDatasetTags || datasetACLCapabilities.canEdit)
const showPreviewOnlyAccessWarning = () => {
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
@ -119,7 +110,6 @@ const DatasetCard = ({
aria-label={isPreviewOnly ? dataset.name : undefined}
className={cardClassName}
data-disable-nprogress={true}
data-step-by-step-tour-target={stepByStepTourCardTarget}
onClick={handleCardClick}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
@ -143,8 +133,6 @@ const DatasetCard = ({
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
openAccessConfig={openAccessConfig}
stepByStepTourHighlightPart={stepByStepTourActionMenuHighlightPart}
stepByStepTourOpen={stepByStepTourActionMenuOpen}
/>
)}
</div>

View File

@ -19,9 +19,6 @@ type Props = Readonly<{
isPlaceholderData: ReturnType<typeof useDatasetList>['isPlaceholderData']
emptyElement?: ReactNode
onOpenTagManagement?: () => void
stepByStepTourActionMenuHighlightPart?: string
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
}>
const Datasets = ({
@ -34,9 +31,6 @@ const Datasets = ({
isPlaceholderData,
emptyElement,
onOpenTagManagement = () => { },
stepByStepTourActionMenuHighlightPart,
stepByStepTourActionMenuOpen,
stepByStepTourCardTarget,
}: Props) => {
const { t } = useTranslation()
const invalidDatasetList = useInvalidDatasetList()
@ -70,17 +64,8 @@ const Datasets = ({
<nav className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
{showDatasetSkeleton
? <DatasetCardSkeleton label={t('loading', { ns: 'common' })} />
: datasets.map((dataset, index) => (
<DatasetCard
key={dataset.id}
dataset={dataset}
onSuccess={invalidDatasetList}
onOpenTagManagement={onOpenTagManagement}
stepByStepTourActionMenuHighlightPart={index === 0 && stepByStepTourActionMenuOpen ? stepByStepTourActionMenuHighlightPart : undefined}
stepByStepTourActionMenuOpen={index === 0 ? stepByStepTourActionMenuOpen : undefined}
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
/>
),
: datasets.map(dataset => (
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
)}
{!showDatasetSkeleton && !hasAnyDataset && emptyElement}
{isFetchingNextPage && <Loading />}

View File

@ -1,10 +1,9 @@
import { render, screen } from '@testing-library/react'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import DatasetFirstEmptyState from '..'
vi.mock('@/next/link', () => ({
default: ({ children, href, className, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} className={className} {...props}>{children}</a>
default: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => (
<a href={href} className={className}>{children}</a>
),
}))
@ -18,17 +17,6 @@ describe('DatasetFirstEmptyState', () => {
expect(pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
})
it('exposes step-by-step tour targets for the empty knowledge actions', () => {
render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.createTitle/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate)
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline)
expect(screen.getByRole('link', { name: /dataset\.connectDataset/ }))
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect)
})
it('lays out placeholder cards with auto-fill grid columns', () => {
const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))

View File

@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `dataset-placeholder-card-${index}`)
@ -10,7 +9,6 @@ type EmptyCreateAction = {
href: string
icon: ReactNode
id: string
target: string
title: string
description: string
}
@ -33,7 +31,6 @@ function DatasetFirstEmptyState({
href: '/datasets/create',
icon: <span aria-hidden className="i-ri-add-line size-4" />,
id: 'create',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate,
title: t('firstEmpty.createTitle', { ns: 'dataset' }),
description: t('firstEmpty.createDescription', { ns: 'dataset' }),
},
@ -41,7 +38,6 @@ function DatasetFirstEmptyState({
href: '/datasets/create-from-pipeline',
icon: <span aria-hidden className="i-custom-vender-pipeline-pipeline-line size-4" />,
id: 'pipeline',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline,
title: t('firstEmpty.pipelineTitle', { ns: 'dataset' }),
description: t('firstEmpty.pipelineDescription', { ns: 'dataset' }),
},
@ -52,7 +48,6 @@ function DatasetFirstEmptyState({
href: '/datasets/connect',
icon: <span aria-hidden className="i-custom-vender-solid-development-api-connection-mod size-4" />,
id: 'connect',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect,
title: t('connectDataset', { ns: 'dataset' }),
description: t('firstEmpty.connectDescription', { ns: 'dataset' }),
}
@ -93,7 +88,6 @@ function DatasetFirstEmptyState({
description={action.description}
href={action.href}
icon={action.icon}
stepByStepTourTarget={action.target}
title={action.title}
visualStyle="list"
/>
@ -112,7 +106,6 @@ function DatasetFirstEmptyState({
description={connectAction.description}
href={connectAction.href}
icon={connectAction.icon}
stepByStepTourTarget={connectAction.target}
title={connectAction.title}
visualStyle="list"
/>

View File

@ -5,7 +5,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
import { TagFilter } from '@/features/tag-management/components/tag-filter'
import ServiceApi from '../extra-info/service-api'
@ -25,9 +24,6 @@ type Props = {
onKeywordsChange: (value: string) => void
onOpenTagManagement: () => void
onTagsChange: (value: string[]) => void
stepByStepTourCreateMenuHighlightPart?: string
stepByStepTourCreateMenuOpen?: boolean
stepByStepTourCreateMenuTarget?: string
}
const DatasetListHeader = ({
@ -46,15 +42,9 @@ const DatasetListHeader = ({
onKeywordsChange,
onOpenTagManagement,
onTagsChange,
stepByStepTourCreateMenuHighlightPart,
stepByStepTourCreateMenuOpen,
stepByStepTourCreateMenuTarget,
}: Props) => {
const { t } = useTranslation()
const showCreateMenu = canCreateDataset || canConnectExternalDataset
const createMenu = useStepByStepTourControlledDropdown({
controlledOpen: stepByStepTourCreateMenuOpen,
})
return (
<div className="sticky top-0 z-10 flex flex-col gap-[14px] bg-background-body px-8 pt-4 pb-2">
@ -104,14 +94,13 @@ const DatasetListHeader = ({
</div>
<div className="flex items-center gap-2">
{showCreateMenu && (
<DropdownMenu modal={false} open={createMenu.open} onOpenChange={createMenu.onOpenChange}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
render={(
<Button
variant="primary"
size="medium"
className="gap-0.5 px-2 shadow-xs"
data-step-by-step-tour-target={stepByStepTourCreateMenuTarget}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="pl-1">{t('operation.create', { ns: 'common' })}</span>
@ -122,12 +111,7 @@ const DatasetListHeader = ({
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: createMenu.controlled,
highlightPart: createMenu.controlled ? stepByStepTourCreateMenuHighlightPart : undefined,
interactionMode: createMenu.controlled ? 'presentation' : 'interactive',
popupClassName: 'w-80',
})}
popupClassName="w-80"
>
{canCreateDataset && (
<>

View File

@ -3,20 +3,14 @@
import { useBoolean, useDebounceFn } from 'ahooks'
// Libraries
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
useSetStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
} from '@/app/components/step-by-step-tour/storage'
import { getStepByStepTourGuides, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
import useDocumentTitle from '@/hooks/use-document-title'
import { useRouter } from '@/next/navigation'
import { useDatasetApiBaseUrl, useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { hasPermission } from '@/utils/permission'
// Components
import FilterEmptyState from '../../base/filter-empty-state'
import ExternalAPIPanel from '../external-api/external-api-panel'
@ -27,7 +21,11 @@ import DatasetListHeader from './header'
const List = () => {
const { t } = useTranslation()
const { push } = useRouter()
const { isCurrentWorkspaceOwner } = useAppContext()
const {
isCurrentWorkspaceOwner,
canCreateDataset,
canConnectExternalDataset,
} = useDatasetWorkspaceAccess()
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
@ -53,13 +51,6 @@ const List = () => {
handleTagsUpdate()
}
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
const datasetListQuery = useDatasetList({
initialPage: 1,
@ -74,44 +65,6 @@ const List = () => {
const hasActiveFilters = tagIDs.length > 0 || keywords.trim().length > 0 || searchKeywords.trim().length > 0 || includeAll
const showEmptyDataList = !hasAnyDataset && (canCreateDataset || canConnectExternalDataset) && hasResolvedFirstPage && !hasActiveFilters
const showFilteredEmptyState = !hasAnyDataset && hasResolvedFirstPage && hasActiveFilters
const hasKnowledgeWalkthroughPermissions = canCreateDataset && canConnectExternalDataset
const activeKnowledgeGuideGroup = hasKnowledgeWalkthroughPermissions
? showEmptyDataList
? 'knowledgeEmpty'
: hasAnyDataset
? 'knowledgeWithDatasets'
: undefined
: undefined
const effectiveActiveKnowledgeGuideGroup = stepByStepTourAccountState.activeGuideGroup ?? activeKnowledgeGuideGroup
const activeKnowledgeGuides = stepByStepTourAccountState.activeTaskId === 'knowledge' && effectiveActiveKnowledgeGuideGroup
? getStepByStepTourGuides('knowledge', effectiveActiveKnowledgeGuideGroup)
: []
const activeKnowledgeGuide = activeKnowledgeGuides[stepByStepTourAccountState.activeGuideIndex ?? 0]
const shouldOpenStepByStepTourCreateMenu = activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate
const shouldOpenStepByStepTourDatasetCardActionMenu = activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard
useEffect(() => {
if (stepByStepTourAccountState.activeTaskId !== 'knowledge')
return
if (!hasResolvedFirstPage || !activeKnowledgeGuideGroup)
return
if (stepByStepTourAccountState.activeGuideGroup === activeKnowledgeGuideGroup)
return
// Sync the active walkthrough branch into the tour storage owner after the
// Knowledge list data resolves.
// eslint-disable-next-line react/set-state-in-effect
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeGuideGroup: activeKnowledgeGuideGroup,
activeGuideIndex: 0,
})
}, [
activeKnowledgeGuideGroup,
hasResolvedFirstPage,
setStepByStepTourAccountState,
stepByStepTourAccountState,
])
return (
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
@ -134,9 +87,6 @@ const List = () => {
onKeywordsChange={handleKeywordsChange}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onTagsChange={handleTagsChange}
stepByStepTourCreateMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
/>
<DatasetFirstEmptyState
canConnectExternalDataset={canConnectExternalDataset}
@ -162,9 +112,6 @@ const List = () => {
onKeywordsChange={handleKeywordsChange}
onOpenTagManagement={() => setShowTagManagementModal(true)}
onTagsChange={handleTagsChange}
stepByStepTourCreateMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
/>
<Datasets
datasetList={datasetListQuery.data}
@ -176,9 +123,6 @@ const List = () => {
isLoading={datasetListQuery.isLoading}
isPlaceholderData={datasetListQuery.isPlaceholderData}
onOpenTagManagement={() => setShowTagManagementModal(true)}
stepByStepTourActionMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourDatasetCardActionMenu : undefined}
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
/>
</>
)}

View File

@ -47,6 +47,17 @@ vi.mock('@/context/app-context', () => ({
},
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: mockUserProfile,
workspacePermissionKeys: mockWorkspacePermissionKeys,
}), () => ({
isRbacEnabled: false,
}))
})
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',

View File

@ -19,17 +19,29 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
}
})
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}),
},
}))
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => mockAppContextState.userProfile,
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
isRbacEnabled: false,
}))
})
// Mock image uploader hooks for AppIconPicker
vi.mock('@/app/components/base/image-uploader/hooks', () => ({
useLocalFileUploader: () => ({

View File

@ -23,6 +23,15 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => ({
userProfile: { id: 'user-1' },
workspacePermissionKeys: [],
}))
})
const createDefaultMockDataset = (): DataSet => ({
id: 'dataset-1',
name: 'Test Dataset',

View File

@ -8,15 +8,14 @@ import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasetPermission } from '@/models/datasets'
import { updateDatasetSetting } from '@/service/datasets'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useMembers } from '@/service/use-common'
import { getDatasetACLCapabilities } from '@/utils/permission'
import { checkShowMultiModalTip } from '../../utils'
const DEFAULT_APP_ICON: IconInfo = {
@ -30,16 +29,7 @@ export const useFormState = () => {
const { t } = useTranslation()
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
const datasetACLCapabilities = useMemo(
() => getDatasetACLCapabilities(currentDataset?.permission_keys, {
currentUserId,
resourceMaintainer: currentDataset?.maintainer,
workspacePermissionKeys,
}),
[currentDataset?.maintainer, currentDataset?.permission_keys, currentUserId, workspacePermissionKeys],
)
const datasetACLCapabilities = useDatasetACLCapabilities(currentDataset)
const canEditSettings = datasetACLCapabilities.canEdit
// Basic form state

View File

@ -4,17 +4,26 @@ import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features
import { DatasetPermission } from '@/models/datasets'
import PermissionSelector from '../index'
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'user-1',
name: 'Current User',
email: 'current@example.com',
avatar_url: '',
role: 'owner',
}),
},
}))
let mockIsRbacEnabled = false
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
isRbacEnabled: mockIsRbacEnabled,
}))
})
describe('PermissionSelector', () => {
const mockMemberList: Member[] = [
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', roles: [], last_login_at: '', created_at: '', status: 'active' }!,
@ -33,6 +42,7 @@ describe('PermissionSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = false
})
describe('Rendering', () => {
@ -409,6 +419,8 @@ describe('PermissionSelector', () => {
})
it('should show access config hint and remain closed when RBAC is enabled', () => {
mockIsRbacEnabled = true
renderWithSystemFeatures(<PermissionSelector {...defaultProps} />, {
systemFeatures: {
rbac_enabled: true,

View File

@ -7,12 +7,13 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import {
useDatasetCurrentUser,
useDatasetRbacEnabled,
} from '@/app/components/datasets/hooks/use-dataset-access'
import { DatasetPermission } from '@/models/datasets'
import MemberItem from './member-item'
import Item from './permission-item'
@ -35,8 +36,8 @@ const PermissionSelector = ({
onMemberSelect,
}: RoleSelectorProps) => {
const { t } = useTranslation()
const userProfile = useAppContextWithSelector(state => state.userProfile)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const userProfile = useDatasetCurrentUser()
const isRbacEnabled = useDatasetRbacEnabled()
const [open, setOpen] = useState(false)
const [keywords, setKeywords] = useState('')
@ -89,7 +90,7 @@ const PermissionSelector = ({
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
const isPartialMembers = permission === DatasetPermission.partialMembers
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
const isDisabledByRBAC = systemFeatures.rbac_enabled
const isDisabledByRBAC = isRbacEnabled
const isDisabled = disabled || isDisabledByRBAC
return (

View File

@ -1,22 +1,12 @@
import type {
StepByStepTourStatePatchPayload,
StepByStepTourStateResponse,
} from '@dify/contracts/api/console/onboarding/types.gen'
import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { StepByStepTourAccountState, StepByStepTourUiState } from '@/app/components/step-by-step-tour/types'
import type { Banner as BannerType } from '@/models/app'
import type { App } from '@/models/explore'
import type { App as WorkspaceApp } from '@/types/app'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import {
StepByStepTourTestStateObserver,
StepByStepTourTestUiStateHydrator,
} from '@/app/components/step-by-step-tour/__tests__/test-utils'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useAppContext } from '@/context/app-context'
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
import { renderWithNuqs } from '@/test/nuqs-testing'
@ -44,107 +34,6 @@ let mockIsError = false
const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn()
const mockTrackCreateApp = vi.fn()
const mockStepByStepTour = vi.hoisted(() => {
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
const createState = (
overrides: Partial<StepByStepTourStateResponse> = {},
): StepByStepTourStateResponse => ({
eligible: true,
first_workspace_id: 'workspace-1',
skipped: false,
completed_task_ids: [],
manually_enabled_workspace_ids: ['workspace-1'],
manually_disabled_workspace_ids: [],
updated_at: '2026-07-01T00:00:00Z',
...overrides,
})
const createUiState = (
overrides: Partial<StepByStepTourUiState> = {},
): StepByStepTourUiState => ({
activeGuideGroup: undefined,
activeGuideIndex: undefined,
activeGuideIndexes: undefined,
activeTaskId: undefined,
minimized: false,
...overrides,
})
let state = createState()
let uiState: StepByStepTourUiState = createUiState()
let observedState: StepByStepTourAccountState | undefined
const patchState = vi.fn(
async ({ body }: { body: StepByStepTourStatePatchPayload }): Promise<StepByStepTourStateResponse> => {
switch (body.action) {
case 'complete_task':
state = {
...state,
completed_task_ids: body.task_id && !state.completed_task_ids?.includes(body.task_id)
? [...(state.completed_task_ids ?? []), body.task_id]
: state.completed_task_ids,
}
break
case 'uncomplete_task':
state = {
...state,
completed_task_ids: (state.completed_task_ids ?? []).filter(taskId => taskId !== body.task_id),
}
break
case 'skip':
state = {
...state,
skipped: true,
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
}
break
case 'enable_current_workspace':
state = {
...state,
skipped: false,
manually_enabled_workspace_ids: Array.from(new Set([...(state.manually_enabled_workspace_ids ?? []), 'workspace-1'])),
manually_disabled_workspace_ids: (state.manually_disabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
}
break
case 'disable_current_workspace':
state = {
...state,
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
manually_disabled_workspace_ids: Array.from(new Set([...(state.manually_disabled_workspace_ids ?? []), 'workspace-1'])),
}
break
}
return state
},
)
return {
get observedState() {
return observedState
},
get state() {
return state
},
get uiState() {
return uiState
},
patchState,
reset() {
state = createState()
uiState = createUiState()
observedState = undefined
patchState.mockClear()
},
setObservedState(nextState: StepByStepTourAccountState) {
observedState = nextState
},
setState(overrides: Partial<StepByStepTourStateResponse> = {}) {
state = createState(overrides)
},
setUiState(overrides: Partial<StepByStepTourUiState> = {}) {
uiState = createUiState(overrides)
},
stateQueryKey,
}
})
const toastMocks = vi.hoisted(() => {
const record = vi.fn()
const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), {
@ -223,24 +112,6 @@ vi.mock('@/service/client', () => ({
},
},
},
onboarding: {
stepByStepTour: {
state: {
get: {
queryKey: () => mockStepByStepTour.stateQueryKey,
queryOptions: () => ({
queryKey: mockStepByStepTour.stateQueryKey,
queryFn: async () => mockStepByStepTour.state,
}),
},
patch: {
mutationOptions: () => ({
mutationFn: mockStepByStepTour.patchState,
}),
},
},
},
},
explore: {
apps: {
get: {
@ -319,27 +190,9 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({
}))
vi.mock('../../try-app', () => ({
default: ({
canCreate = true,
createButtonStepByStepTourTarget,
onCreate,
onClose,
}: {
canCreate?: boolean
createButtonStepByStepTourTarget?: string
onCreate: () => void
onClose: () => void
}) => (
default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
<div data-testid="try-app-panel">
{canCreate && (
<button
data-testid="try-app-create"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
create
</button>
)}
<button data-testid="try-app-create" onClick={onCreate}>create</button>
<button data-testid="try-app-close" onClick={onClose}>close</button>
</div>
),
@ -430,11 +283,6 @@ const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({
const mockAppCreatePermission = (hasEditPermission: boolean) => {
;(useAppContext as Mock).mockReturnValue({
currentWorkspace: {
id: 'workspace-1',
name: 'Solar Studio',
role: 'owner',
},
userProfile: { id: 'user-1' },
workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [],
})
@ -468,7 +316,6 @@ const renderAppList = (
queryClient.setQueryData(exploreAppListQueryKey, mockExploreData)
if (options.enableExploreBanner && !mockBannersLoading)
queryClient.setQueryData(exploreBannersQueryKey, mockBanners)
queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state)
const mockFetchAppList = fetchAppList as unknown as Mock
const mockFetchBanners = fetchBanners as unknown as Mock
@ -496,12 +343,7 @@ const renderAppList = (
const Wrapped = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>
<SystemFeaturesWrapper>
<StepByStepTourTestUiStateHydrator initialState={mockStepByStepTour.uiState}>
<StepByStepTourTestStateObserver onChange={mockStepByStepTour.setObservedState} />
{children}
</StepByStepTourTestUiStateHydrator>
</SystemFeaturesWrapper>
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
</JotaiProvider>
)
const rendered = renderWithNuqs(
@ -539,7 +381,6 @@ describe('AppList', () => {
mockIsLoading = false
mockIsError = false
mockConfig.isCloudEdition = false
mockStepByStepTour.reset()
})
afterEach(() => {
@ -699,12 +540,7 @@ describe('AppList', () => {
renderAppList()
const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' })
expect(learnDifyHeading).toBeInTheDocument()
expect(learnDifyHeading.closest('section')).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.home,
)
expect(screen.getByRole('heading', { name: 'explore.learnDify.title' })).toBeInTheDocument()
expect(screen.getByText('Learn Workflow Basics')).toBeInTheDocument()
expect(screen.getByText('Learn Agent Basics')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'explore.learnDify.moreTemplates' })).not.toBeInTheDocument()
@ -822,8 +658,8 @@ describe('AppList', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
options.onPending?.()
})
@ -858,8 +694,8 @@ describe('AppList', () => {
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
};
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
@ -871,237 +707,6 @@ describe('AppList', () => {
await waitFor(() => {
expect(fetchAppDetail).toHaveBeenCalledWith('learn-basic-1')
})
expect(mockHandleImportDSL).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
skipRedirectOnSuccess: false,
}),
)
})
it('should advance the Learn Dify tour to the create button after a lesson opens', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
})
renderAppList(true, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
expect(screen.getByTestId('try-app-create')).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
)
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBe('home')
expect(state?.activeGuideIndex).toBe(1)
expect(state?.completedTaskIds).toEqual([])
})
})
it('should complete the Learn Dify tour when a no-create user opens a lesson detail', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
})
renderAppList(false, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
expect(screen.queryByTestId('try-app-create')).not.toBeInTheDocument()
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBeUndefined()
expect(state?.activeGuideIndex).toBeUndefined()
expect(state?.activeGuideGroup).toBeUndefined()
expect(state?.completedTaskIds).toEqual(['home'])
expect(state?.minimized).toBe(false)
})
})
it('should complete the Learn Dify tour only after the app is created from details', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
});
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
fireEvent.click(await screen.findByTestId('try-app-create'))
fireEvent.click(await screen.findByTestId('confirm-create'))
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBeUndefined()
expect(state?.activeGuideIndex).toBeUndefined()
expect(state?.completedTaskIds).toEqual(['home'])
})
expect(mockHandleImportDSL).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
skipRedirectOnSuccess: true,
}),
)
})
it('should skip redirect after confirming a pending Learn Dify tour create', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
});
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
options.onPending?.()
})
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
})
renderAppList(true, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
fireEvent.click(await screen.findByTestId('try-app-create'))
fireEvent.click(await screen.findByTestId('confirm-create'))
fireEvent.click(await screen.findByTestId('dsl-confirm'))
await waitFor(() => {
expect(mockHandleImportDSLConfirm).toHaveBeenCalledWith(
expect.objectContaining({
skipRedirectOnSuccess: true,
}),
)
})
})
it('should hide the Learn Dify tour target while the create modal is open and abandon on cancel', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
})
renderAppList(true, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
const createFromDetailsButton = await screen.findByTestId('try-app-create')
expect(createFromDetailsButton).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
)
fireEvent.click(createFromDetailsButton)
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
expect(createFromDetailsButton).not.toHaveAttribute('data-step-by-step-tour-target')
fireEvent.click(screen.getByTestId('hide-create'))
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBeUndefined()
expect(state?.activeGuideIndex).toBeUndefined()
expect(state?.completedTaskIds).toEqual([])
})
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
})
it('should restart the Learn Dify tour from a clean state after abandoning create', async () => {
vi.useRealTimers()
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
})
const { unmount } = renderAppList(true, undefined, undefined, {
isCloudEdition: true,
})
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
fireEvent.click(await screen.findByTestId('try-app-create'))
fireEvent.click(await screen.findByTestId('hide-create'))
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBeUndefined()
expect(state?.activeGuideIndex).toBeUndefined()
})
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
const abandonedTourState = mockStepByStepTour.observedState
if (!abandonedTourState)
throw new Error('Step-by-step tour state should be ready before restarting the home tour.')
unmount()
mockStepByStepTour.setState({
active_task_id: 'home',
active_guide_index: 0,
completed_task_ids: abandonedTourState.completedTaskIds,
first_workspace_id: abandonedTourState.firstWorkspaceId,
manually_disabled_workspace_ids: abandonedTourState.manuallyDisabledWorkspaceIds,
manually_enabled_workspace_ids: abandonedTourState.manuallyEnabledWorkspaceIds,
minimized: true,
skipped: abandonedTourState.skipped,
})
mockStepByStepTour.setUiState({
activeTaskId: 'home',
activeGuideIndex: 0,
minimized: true,
})
renderAppList(true, undefined, undefined, { isCloudEdition: true })
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
expect(screen.getByTestId('try-app-create')).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
)
await waitFor(() => {
const state = mockStepByStepTour.observedState
expect(state?.activeTaskId).toBe('home')
expect(state?.activeGuideIndex).toBe(1)
})
})
})

View File

@ -4,7 +4,6 @@ import type { App } from '@/models/explore'
import type { App as WorkspaceApp } from '@/types/app'
import type { TryAppSelection } from '@/types/try-app'
import ContinueWork from '@/app/components/explore/continue-work'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import dynamic from '@/next/dynamic'
const LearnDify = dynamic(() => import('@/app/components/explore/learn-dify'), { ssr: false })
@ -12,13 +11,11 @@ const LearnDify = dynamic(() => import('@/app/components/explore/learn-dify'), {
export function ExploreRecommendations({
canCreate,
continueWorkApps,
forceShowLearnDify,
onCreate,
onTry,
}: {
canCreate: boolean
continueWorkApps: WorkspaceApp[]
forceShowLearnDify?: boolean
onCreate: (app: App) => void
onTry: (params: TryAppSelection) => void
}) {
@ -28,10 +25,8 @@ export function ExploreRecommendations({
<LearnDify
canCreate={canCreate}
className="pb-0"
forceVisible={forceShowLearnDify}
onCreate={onCreate}
onTry={onTry}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home}
/>
</>
)

View File

@ -1,7 +1,6 @@
'use client'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { StepByStepTourPersistentState, StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types'
import type { Banner as BannerType } from '@/models/app'
import type { App } from '@/models/explore'
import type { App as WorkspaceApp } from '@/types/app'
@ -18,20 +17,6 @@ import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-conf
import AppCard from '@/app/components/explore/app-card'
import Banner from '@/app/components/explore/banner/banner'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import {
buildStepByStepTourScopedWorkspaceProperties,
buildStepByStepTourWorkspaceProperties,
getStepByStepTourPermissionVariant,
STEP_BY_STEP_TOUR_ANALYTICS_EVENTS,
trackStepByStepTourEvent,
} from '@/app/components/step-by-step-tour/analytics'
import { STEP_BY_STEP_TOUR_TASKS } from '@/app/components/step-by-step-tour/constants'
import {
useSetStepByStepTourAccountState,
useStepByStepTourAccountStateValue,
useStepByStepTourStateActions,
} from '@/app/components/step-by-step-tour/storage'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -64,7 +49,6 @@ const homeContinueWorkAppsInput = {
}
const disabledBannersQueryKey = ['explore', 'home', 'banners', 'disabled'] as const
const HOME_STEP_BY_STEP_TOUR_TASK_ID = 'home' satisfies StepByStepTourTaskId
function getLocaleQueryInput(locale?: string) {
return locale
@ -117,7 +101,7 @@ function getDisabledBannersQueryOptions() {
const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
const { t } = useTranslation()
const locale = useLocale()
const { currentWorkspace, workspacePermissionKeys } = useAppContext()
const { workspacePermissionKeys } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(
systemFeaturesQueryOptions(),
)
@ -139,39 +123,6 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
})
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
// eslint-disable-next-line react/use-state -- Step-by-step tour state actions are not React useState calls.
const stepByStepTourActions = useStepByStepTourStateActions()
const currentWorkspaceId = currentWorkspace.id
const trackHomeTourCompleted = useCallback((state: StepByStepTourPersistentState) => {
trackStepByStepTourEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENTS.taskCompleted, {
...buildStepByStepTourWorkspaceProperties({ currentWorkspaceId }),
task_id: HOME_STEP_BY_STEP_TOUR_TASK_ID,
completed_task_count: state.completedTaskIds.length,
completion_source: 'external_action',
permission_variant: getStepByStepTourPermissionVariant({
canCreateApp,
hasIntegrationWalkthroughPermissions: true,
hasKnowledgeWalkthroughPermissions: true,
taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID,
}),
task_total: STEP_BY_STEP_TOUR_TASKS.length,
})
if (STEP_BY_STEP_TOUR_TASKS.every(task => state.completedTaskIds.includes(task.id))) {
trackStepByStepTourEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENTS.completed, {
...buildStepByStepTourScopedWorkspaceProperties({
accountState: state,
currentWorkspaceId,
}),
completed_task_ids: state.completedTaskIds,
task_total: STEP_BY_STEP_TOUR_TASKS.length,
})
}
}, [canCreateApp, currentWorkspaceId])
const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('')
@ -247,125 +198,21 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
TrackCreateAppParams,
'source' | 'templateId'
> | null>(null)
const isCurrentTryAppFromLearnDifyRef = useRef(false)
const shouldCompleteHomeTourOnCreateRef = useRef(false)
const isSubmittingHomeTourCreateRef = useRef(false)
const isShowTryAppPanel = !!currentTryApp
const shouldForceShowLearnDifyForTour = stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
&& (stepByStepTourAccountState.activeGuideIndex ?? 0) === 0
const abandonHomeTour = useCallback(() => {
if (
stepByStepTourAccountState.activeTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID
|| stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
) {
return
}
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeTaskId: undefined,
activeGuideIndex: undefined,
activeGuideGroup: undefined,
minimized: true,
})
}, [setStepByStepTourAccountState, stepByStepTourAccountState])
const completeHomeTourAfterCreate = useCallback(() => {
if (!shouldCompleteHomeTourOnCreateRef.current)
return
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeTaskId: undefined,
activeGuideIndex: undefined,
activeGuideGroup: undefined,
minimized: false,
})
stepByStepTourActions.completeTask(HOME_STEP_BY_STEP_TOUR_TASK_ID, {
onSuccess: trackHomeTourCompleted,
})
isCurrentTryAppFromLearnDifyRef.current = false
shouldCompleteHomeTourOnCreateRef.current = false
isSubmittingHomeTourCreateRef.current = false
}, [setStepByStepTourAccountState, stepByStepTourAccountState, stepByStepTourActions, trackHomeTourCompleted])
const completeHomeTourAfterOpenDetails = useCallback(() => {
if (
stepByStepTourAccountState.activeTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID
|| stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|| (stepByStepTourAccountState.activeGuideIndex ?? 0) !== 0
) {
return
}
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeTaskId: undefined,
activeGuideIndex: undefined,
activeGuideGroup: undefined,
minimized: false,
})
stepByStepTourActions.completeTask(HOME_STEP_BY_STEP_TOUR_TASK_ID, {
onSuccess: trackHomeTourCompleted,
})
}, [setStepByStepTourAccountState, stepByStepTourAccountState, stepByStepTourActions, trackHomeTourCompleted])
const abandonHomeTourCreate = useCallback(() => {
if (!isCurrentTryAppFromLearnDifyRef.current || isSubmittingHomeTourCreateRef.current)
return
abandonHomeTour()
setCurrentTryApp(undefined)
setCurrApp(null)
currentCreateAppTrackingRef.current = null
currentCreateAppModeRef.current = null
isCurrentTryAppFromLearnDifyRef.current = false
shouldCompleteHomeTourOnCreateRef.current = false
}, [abandonHomeTour])
const hideTryAppPanel = useCallback(() => {
abandonHomeTourCreate()
setCurrentTryApp(undefined)
}, [abandonHomeTourCreate])
}, [])
const handleTryApp = useCallback((params: TryAppSelection) => {
isCurrentTryAppFromLearnDifyRef.current = false
setCurrentTryApp(params)
}, [])
const handleTryAppFromLearnDify = useCallback((params: TryAppSelection) => {
isCurrentTryAppFromLearnDifyRef.current = true
setCurrentTryApp(params)
if (
stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
&& (stepByStepTourAccountState.activeGuideIndex ?? 0) === 0
) {
if (!canCreateApp) {
completeHomeTourAfterOpenDetails()
isCurrentTryAppFromLearnDifyRef.current = false
return
}
setStepByStepTourAccountState({
...stepByStepTourAccountState,
activeGuideIndex: 1,
minimized: true,
})
}
}, [canCreateApp, completeHomeTourAfterOpenDetails, setStepByStepTourAccountState, stepByStepTourAccountState])
const handleShowFromTryApp = useCallback(() => {
setCurrApp(currentTryApp?.app || null)
currentCreateAppTrackingRef.current = {
source: 'explore_template_preview',
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
}
shouldCompleteHomeTourOnCreateRef.current = isCurrentTryAppFromLearnDifyRef.current
&& stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
&& stepByStepTourAccountState.activeGuideIndex === 1
setIsShowCreateModal(true)
}, [currentTryApp?.app, currentTryApp?.appId, stepByStepTourAccountState])
}, [currentTryApp?.app, currentTryApp?.appId])
const handleCreateFromLearnDify = useCallback((app: App) => {
setCurrApp(app)
setIsShowCreateModal(true)
@ -394,16 +241,9 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
},
[],
)
const handleCreateModalHide = useCallback(() => {
if (!isSubmittingHomeTourCreateRef.current)
abandonHomeTourCreate()
setIsShowCreateModal(false)
}, [abandonHomeTourCreate])
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(
async ({ name, icon_type, icon, icon_background, description }) => {
isSubmittingHomeTourCreateRef.current = shouldCompleteHomeTourOnCreateRef.current
hideTryAppPanel()
const appId = currApp?.app.id
@ -421,44 +261,27 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
icon_background,
description,
}
let didTransitionCreateFlow = false
await handleImportDSL(payload, {
onSuccess: (response) => {
didTransitionCreateFlow = true
trackCurrentCreateApp(response.app_mode)
completeHomeTourAfterCreate()
setIsShowCreateModal(false)
},
onPending: () => {
didTransitionCreateFlow = true
setShowDSLConfirmModal(true)
},
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
})
if (!didTransitionCreateFlow && shouldCompleteHomeTourOnCreateRef.current) {
isSubmittingHomeTourCreateRef.current = false
abandonHomeTourCreate()
}
},
[abandonHomeTourCreate, completeHomeTourAfterCreate, currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp],
[currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp],
)
const onConfirmDSL = useCallback(async () => {
await handleImportDSLConfirm({
onSuccess: (response) => {
trackCurrentCreateApp(response.app_mode)
completeHomeTourAfterCreate()
onSuccess?.()
},
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
})
}, [completeHomeTourAfterCreate, handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
const handleCancelDSLConfirm = useCallback(() => {
setShowDSLConfirmModal(false)
isSubmittingHomeTourCreateRef.current = false
abandonHomeTourCreate()
}, [abandonHomeTourCreate])
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
if (homeQueries.isAppListError)
return null
@ -482,9 +305,8 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
<ExploreRecommendations
canCreate={canCreateApp}
continueWorkApps={homeQueries.continueWorkApps}
forceShowLearnDify={shouldForceShowLearnDifyForTour}
onCreate={handleCreateFromLearnDify}
onTry={handleTryAppFromLearnDify}
onTry={handleTryApp}
/>
<ExploreAppListHeader
@ -528,13 +350,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
show={isShowCreateModal}
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={handleCreateModalHide}
onHide={() => setIsShowCreateModal(false)}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={handleCancelDSLConfirm}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
@ -544,13 +366,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
<TryApp
appId={currentTryApp?.appId || ''}
app={currentTryApp?.app}
canCreate={canCreateApp}
categories={currentTryApp?.app?.categories}
createButtonStepByStepTourTarget={
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate
: undefined
}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>

View File

@ -1,6 +1,6 @@
import type { ComponentProps } from 'react'
import type { Banner } from '@/models/app'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BannerItem } from '../banner-item'
@ -37,11 +37,9 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
const mockResizeObserverObserve = vi.fn()
const mockResizeObserverDisconnect = vi.fn()
let mockResizeObserverCallbacks: ResizeObserverCallback[] = []
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
mockResizeObserverCallbacks.push(callback)
constructor(_callback: ResizeObserverCallback) {
}
observe(...args: Parameters<ResizeObserver['observe']>) {
@ -71,9 +69,6 @@ const renderBannerItem = (
)
}
const getIndicatorButtons = () =>
screen.queryAllByText(/^\d{2}$/).map(label => label.closest('button'))
describe('BannerItem', () => {
let mockWindowOpen: ReturnType<typeof vi.spyOn>
@ -82,7 +77,6 @@ describe('BannerItem', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
vi.stubGlobal('ResizeObserver', MockResizeObserver)
mockResizeObserverCallbacks = []
Object.defineProperty(window, 'innerWidth', {
writable: true,
@ -134,7 +128,8 @@ describe('BannerItem', () => {
const banner = createMockBanner({ link: 'https://test-link.com' })
renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
fireEvent.click(screen.getByRole('button', { name: 'Test Banner Title' }))
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
banner_id: 'banner-1',
@ -157,7 +152,8 @@ describe('BannerItem', () => {
const banner = createMockBanner({ link: '' })
renderBannerItem(banner)
fireEvent.click(screen.getByRole('button', { name: 'Test Banner Title' }))
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
fireEvent.click(bannerElement!)
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
link: '',
@ -170,11 +166,7 @@ describe('BannerItem', () => {
it('renders correct number of indicator buttons', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
renderBannerItem()
expect(['01', '02', '03'].map(label => screen.getByText(label).closest('button'))).toEqual([
expect.any(HTMLButtonElement),
expect.any(HTMLButtonElement),
expect.any(HTMLButtonElement),
])
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('renders indicator buttons with correct numbers', () => {
@ -193,27 +185,12 @@ describe('BannerItem', () => {
fireEvent.click(secondIndicator!)
expect(mockScrollTo).toHaveBeenCalledWith(1)
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('does not activate the banner when an indicator is used from the keyboard', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
renderBannerItem()
const indicatorButton = screen.getByText('02').closest('button')
fireEvent.keyDown(indicatorButton!, { key: 'Enter' })
fireEvent.click(indicatorButton!)
expect(mockScrollTo).toHaveBeenCalledWith(1)
expect(mockTrackEvent).not.toHaveBeenCalled()
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('renders no indicators when no slides', () => {
mockSlideNodes.mockReturnValue([])
renderBannerItem()
expect(getIndicatorButtons()).toHaveLength(0)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
})
@ -235,33 +212,6 @@ describe('BannerItem', () => {
expect(mockResizeObserverObserve).toHaveBeenCalled()
})
it('schedules responsive width updates outside the ResizeObserver callback', () => {
const animationFrameCallbacks: FrameRequestCallback[] = []
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
animationFrameCallbacks.push(callback)
return animationFrameCallbacks.length
})
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
try {
renderBannerItem()
act(() => {
animationFrameCallbacks.splice(0).forEach(callback => callback(0))
})
requestAnimationFrameSpy.mockClear()
act(() => {
mockResizeObserverCallbacks[0]?.([], {} as ResizeObserver)
})
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1)
}
finally {
requestAnimationFrameSpy.mockRestore()
cancelAnimationFrameSpy.mockRestore()
}
})
it('adds resize event listener on mount', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
renderBannerItem()
@ -373,20 +323,21 @@ describe('BannerItem', () => {
it('calculates next index correctly for first slide', () => {
mockSlideNodes.mockReturnValue([{}, {}, {}])
renderBannerItem()
expect(getIndicatorButtons()).toHaveLength(3)
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('handles single slide case', () => {
mockSlideNodes.mockReturnValue([{}])
renderBannerItem()
expect(getIndicatorButtons()).toHaveLength(1)
expect(screen.getAllByRole('button')).toHaveLength(1)
})
})
describe('wrapper styling', () => {
it('shows pointer affordance on the banner activation control', () => {
renderBannerItem()
expect(screen.getByRole('button', { name: 'Test Banner Title' })).toHaveClass('cursor-pointer')
it('has cursor-pointer class', () => {
const { container } = renderBannerItem()
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('cursor-pointer')
})
it('has rounded-2xl class', () => {

View File

@ -1,5 +1,4 @@
/* eslint-disable react/set-state-in-effect */
import type { KeyboardEvent } from 'react'
import type { Banner } from '@/models/app'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -36,7 +35,6 @@ export function BannerItem({
const [resetKey, setResetKey] = useState(0)
const textAreaRef = useRef<HTMLDivElement>(null)
const maxWidthFrameRef = useRef<number | undefined>(undefined)
const [maxWidth, setMaxWidth] = useState<number | undefined>(undefined)
const slideInfo = useMemo(() => {
@ -78,44 +76,30 @@ export function BannerItem({
const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), [])
const updateMaxWidth = useCallback(() => {
if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
const textAreaWidth = textAreaRef.current.offsetWidth
setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
}
else {
setMaxWidth(undefined)
}
}, [])
const scheduleMaxWidthUpdate = useCallback(() => {
if (maxWidthFrameRef.current !== undefined)
return
maxWidthFrameRef.current = window.requestAnimationFrame(() => {
maxWidthFrameRef.current = undefined
updateMaxWidth()
})
}, [updateMaxWidth])
useEffect(() => {
scheduleMaxWidthUpdate()
const updateMaxWidth = () => {
if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
const textAreaWidth = textAreaRef.current.offsetWidth
setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
}
else {
setMaxWidth(undefined)
}
}
const resizeObserver = new ResizeObserver(scheduleMaxWidthUpdate)
updateMaxWidth()
const resizeObserver = new ResizeObserver(updateMaxWidth)
if (textAreaRef.current)
resizeObserver.observe(textAreaRef.current)
window.addEventListener('resize', scheduleMaxWidthUpdate)
window.addEventListener('resize', updateMaxWidth)
return () => {
if (maxWidthFrameRef.current !== undefined) {
window.cancelAnimationFrame(maxWidthFrameRef.current)
maxWidthFrameRef.current = undefined
}
resizeObserver.disconnect()
window.removeEventListener('resize', scheduleMaxWidthUpdate)
window.removeEventListener('resize', updateMaxWidth)
}
}, [scheduleMaxWidthUpdate])
}, [])
useEffect(() => {
incrementResetKey()
@ -143,22 +127,11 @@ export function BannerItem({
if (banner.link)
window.open(banner.link, '_blank', 'noopener,noreferrer')
}, [accountId, banner, incrementResetKey, language, sort])
const handleBannerKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Enter' && event.key !== ' ')
return
event.preventDefault()
handleBannerClick()
}, [handleBannerClick])
return (
<div
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid xl:h-[184px]"
role="button"
tabIndex={0}
aria-label={title}
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs xl:h-[184px]"
onClick={handleBannerClick}
onKeyDown={handleBannerKeyDown}
>
<div className="flex min-w-px flex-1 flex-col items-end self-stretch rounded-2xl py-6 pl-8">
<div className="w-full min-w-0 pr-4" style={responsiveStyle}>

View File

@ -1,5 +1,5 @@
/* eslint-disable react/set-state-in-effect */
import type { FC, KeyboardEvent, MouseEvent } from 'react'
/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useEffect, useRef, useState } from 'react'
@ -64,23 +64,18 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
if (frameIdRef.current)
cancelAnimationFrame(frameIdRef.current)
}
}, [isNextSlide, autoplayDelay, resetKey, isPaused, shouldAnimate])
}, [isNextSlide, autoplayDelay, resetKey, isPaused])
const handleClick = useCallback((e: MouseEvent) => {
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onClick()
}, [onClick])
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter' || event.key === ' ')
event.stopPropagation()
}, [])
const progressDegrees = progress * DEGREES_PER_PERCENT
return (
<button
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'relative flex h-[18px] w-[20px] items-center justify-center rounded-[7px] border border-divider-subtle p-[2px] text-center system-2xs-semibold-uppercase transition-colors',
isActive

View File

@ -1,105 +0,0 @@
import type { App } from '@/models/explore'
import { render, screen } from '@testing-library/react'
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { AppModeEnum } from '@/types/app'
import LearnDify from '../index'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '../storage'
let mockLearnDifyApps: App[] = []
let mockLearnDifyLoading = false
vi.mock('@/service/use-explore', () => ({
useLearnDifyAppList: () => ({
data: mockLearnDifyApps,
isLoading: mockLearnDifyLoading,
}),
}))
const createApp = (overrides: Partial<App> = {}): App => ({
app: {
id: overrides.app?.id ?? 'app-basic-id',
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
icon_type: overrides.app?.icon_type ?? 'emoji',
icon: overrides.app?.icon ?? '😀',
icon_background: overrides.app?.icon_background ?? '#fff',
icon_url: overrides.app?.icon_url ?? '',
name: overrides.app?.name ?? 'Learn Dify App',
description: overrides.app?.description ?? 'Learn Dify description',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
},
can_trial: overrides.can_trial ?? true,
app_id: overrides.app_id ?? 'learn-dify-app',
description: overrides.description ?? 'Learn Dify description',
copyright: overrides.copyright ?? '',
privacy_policy: overrides.privacy_policy ?? null,
custom_disclaimer: overrides.custom_disclaimer ?? null,
categories: overrides.categories ?? ['Writing'],
position: overrides.position ?? 1,
is_listed: overrides.is_listed ?? true,
install_count: overrides.install_count ?? 0,
installed: overrides.installed ?? false,
editable: overrides.editable ?? false,
is_agent: overrides.is_agent ?? false,
})
const renderLearnDify = ({
enableLearnApp = true,
forceVisible = false,
}: {
enableLearnApp?: boolean
forceVisible?: boolean
} = {}) => {
const { wrapper } = createSystemFeaturesWrapper({
systemFeatures: {
enable_learn_app: enableLearnApp,
},
})
return render(
<LearnDify
forceVisible={forceVisible}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home}
/>,
{ wrapper },
)
}
describe('LearnDify', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockLearnDifyApps = [createApp()]
mockLearnDifyLoading = false
})
it('should stay hidden when the user hidden preference is set', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify()
expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument()
})
it('should show hidden content when forceVisible is set for the step tour', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify({ forceVisible: true })
const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' })
expect(learnDifyHeading).toBeInTheDocument()
expect(learnDifyHeading.closest('section')).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.home,
)
expect(screen.queryByRole('button', { name: 'explore.learnDify.hide' })).not.toBeInTheDocument()
})
it('should keep Learn Dify hidden when the system feature is disabled', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify({ enableLearnApp: false, forceVisible: true })
expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument()
})
})

View File

@ -16,13 +16,11 @@ type LearnDifyProps = {
canCreate?: boolean
className?: string
dismissible?: boolean
forceVisible?: boolean
itemLimit?: number
loadingFallback?: React.ReactNode
onCreate?: (app: App) => void
onTry?: (params: TryAppSelection) => void
showDescription?: boolean
stepByStepTourTarget?: string
title?: string
}
@ -39,7 +37,6 @@ const LearnDifyContent = ({
onCreate,
onTry,
showDescription = true,
stepByStepTourTarget,
title,
}: LearnDifyContentProps) => {
const { t } = useTranslation()
@ -96,7 +93,6 @@ const LearnDifyContent = ({
)}
style={isClosing ? { transform: collapseTransform, transformOrigin: 'center center' } : undefined}
aria-labelledby="learn-dify-title"
data-step-by-step-tour-target={stepByStepTourTarget}
>
<div className="-mx-4 rounded-2xl bg-background-section p-4">
<div className="flex items-start justify-between gap-4 pb-2.5">
@ -153,7 +149,7 @@ const LearnDify = (props: LearnDifyProps) => {
if (!systemFeatures.enable_learn_app)
return null
if (props.dismissible === false || props.forceVisible)
if (props.dismissible === false)
return <LearnDifyContent {...props} />
return <DismissibleLearnDify {...props} />

View File

@ -12,10 +12,8 @@ import useGetRequirements from './use-get-requirements'
type Props = Readonly<{
appId: string
appDetail: TryAppInfo
canCreate?: boolean
categories?: string[]
className?: string
createButtonStepByStepTourTarget?: string
onCreate: () => void
}>
@ -53,10 +51,8 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
const AppInfo: FC<Props> = ({
appId,
canCreate = true,
className,
categories,
createButtonStepByStepTourTarget,
appDetail,
onCreate,
}) => {
@ -98,17 +94,10 @@ const AppInfo: FC<Props> = ({
{appDetail.description && (
<div className="mt-[14px] shrink-0 system-sm-regular text-text-secondary">{appDetail.description}</div>
)}
{canCreate && (
<Button
variant="primary"
className="mt-3 flex w-full max-w-full"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button>
)}
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button>
{visibleCategories.length > 0 && (
<div className="mt-6 shrink-0">

View File

@ -22,9 +22,7 @@ import { TypeEnum } from './types'
type Props = Readonly<{
appId: string
app?: AppType
canCreate?: boolean
categories?: string[]
createButtonStepByStepTourTarget?: string
onClose: () => void
onCreate: () => void
}>
@ -32,9 +30,7 @@ type Props = Readonly<{
const TryApp: FC<Props> = ({
appId,
app,
canCreate = true,
categories,
createButtonStepByStepTourTarget,
onClose,
onCreate,
}) => {
@ -116,9 +112,7 @@ const TryApp: FC<Props> = ({
className="w-[360px] shrink-0"
appDetail={appDetail}
appId={appId}
canCreate={canCreate}
categories={categories}
createButtonStepByStepTourTarget={createButtonStepByStepTourTarget}
onCreate={onCreate}
/>
</div>

View File

@ -1,5 +1,4 @@
import { useTranslation } from 'react-i18next'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useDocLink } from '@/context/i18n'
export function Empty() {
@ -7,10 +6,7 @@ export function Empty() {
const docLink = useDocLink()
return (
<div
className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6"
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty}
>
<div className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6">
<div className="flex size-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-xs">
<span aria-hidden className="i-custom-vender-workflow-api-aggregate size-5 text-text-tertiary" />
</div>

View File

@ -6,7 +6,6 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { consoleQuery } from '@/service/client'
import { hasPermission } from '@/utils/permission'
@ -135,17 +134,13 @@ export function ApiBasedExtensionPage({
}
{
!isLoading && !!filteredApiBasedExtensions.length && (
filteredApiBasedExtensions.map((item, index) => (
<div
filteredApiBasedExtensions.map(item => (
<Item
key={item.id}
data-step-by-step-tour-target={index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty : undefined}
>
<Item
apiBasedExtension={item}
onEdit={handleEditApiBasedExtension}
canManage={canManage}
/>
</div>
apiBasedExtension={item}
onEdit={handleEditApiBasedExtension}
canManage={canManage}
/>
))
)
}

View File

@ -7,7 +7,6 @@ import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/compone
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useGetDataSourceListAuth, useInvalidDataSourceListAuth } from '@/service/use-datasource'
@ -126,7 +125,7 @@ const DataSourcePage = ({
<>
{isDataSourceListLoading && <DataSourceListSkeleton />}
{!isDataSourceListLoading && !dataSources.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4" data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceFirstCard}>
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
<span className="i-ri-database-2-line h-5 w-5 text-text-primary" />
</div>
@ -147,20 +146,16 @@ const DataSourcePage = ({
{!isDataSourceListLoading && !!filteredDataSources.length && (
<div className="space-y-2">
{
filteredDataSources.map((item, index) => {
filteredDataSources.map((item) => {
const pluginDetail = dataSourcePluginDetails.find(plugin => plugin.plugin_id === item.plugin_id)
return (
<div
<Card
key={item.plugin_unique_identifier}
data-step-by-step-tour-target={index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceFirstCard : undefined}
>
<Card
item={item}
pluginDetail={pluginDetail}
onPluginUpdate={handlePluginUpdate}
/>
</div>
item={item}
pluginDetail={pluginDetail}
onPluginUpdate={handlePluginUpdate}
/>
)
})
}

View File

@ -4,7 +4,6 @@ import { act, fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@ -515,16 +514,6 @@ describe('ModelProviderPage', () => {
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should use the empty provider state as the production tour target when no provider cards exist', () => {
mockProviders.splice(0)
renderModelProviderPage()
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction)
const target = document.querySelector(selector)
expect(target).toContainElement(screen.getByText('common.modelProvider.emptyProviderTitle'))
})
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'langgenius/openai/openai',
@ -679,9 +668,6 @@ describe('ModelProviderPage', () => {
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction)
const target = document.querySelector(selector)
expect(target).toContainElement(screen.getByText('anthropic'))
})
it('should show none-configured warning when providers exist but no default models set', () => {

View File

@ -3,7 +3,6 @@ import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { useMarketplaceAllPlugins } from '../hooks'
import InstallFromMarketplace from '../install-from-marketplace'
@ -114,34 +113,6 @@ describe('InstallFromMarketplace', () => {
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
})
it('should anchor the tour target to the heading and first marketplace row', () => {
(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({
plugins: [
{ plugin_id: '1', name: 'Plugin 1' },
{ plugin_id: '2', name: 'Plugin 2' },
{ plugin_id: '3', name: 'Plugin 3' },
{ plugin_id: '4', name: 'Plugin 4' },
],
isLoading: false,
})
render(
<InstallFromMarketplace
providers={mockProviders}
searchText=""
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
/>,
)
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall)
const target = document.querySelector<HTMLElement>(selector)
expect(target).toHaveClass('absolute', 'inset-x-0', 'top-0', 'h-[174px]')
expect(target).toHaveAttribute('aria-hidden', 'true')
expect(target?.parentElement).toContainElement(screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ }))
expect(target?.parentElement).toContainElement(screen.getByTestId('plugin-list'))
})
it('should hide bundle plugins from the list', () => {
(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({
plugins: [

View File

@ -22,13 +22,11 @@ type InstallFromMarketplaceProps = {
onOpenMarketplace?: () => void
providers: ModelProvider[]
searchText: string
stepByStepTourTarget?: string
}
const InstallFromMarketplace = ({
onOpenMarketplace,
providers,
searchText,
stepByStepTourTarget,
}: InstallFromMarketplaceProps) => {
const { t } = useTranslation()
const { theme } = useTheme()
@ -49,63 +47,56 @@ const InstallFromMarketplace = ({
return (
<div id="model-provider-marketplace" className="flex scroll-mt-4 flex-col gap-2">
<Divider className="my-2! h-px" />
<div className="relative flex flex-col gap-2">
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[174px]"
data-step-by-step-tour-target={stepByStepTourTarget}
/>
<div className="flex h-5 items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
onClick={() => setCollapse(prev => !prev)}
aria-expanded={!collapse}
>
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
{t('modelProvider.installProvider', { ns: 'common' })}
</button>
<div className="flex items-center gap-1">
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
<div className="flex h-5 items-center justify-between">
<button
type="button"
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
onClick={() => setCollapse(prev => !prev)}
aria-expanded={!collapse}
>
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
{t('modelProvider.installProvider', { ns: 'common' })}
</button>
<div className="flex items-center gap-1">
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
{onOpenMarketplace
? (
<button
type="button"
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
onClick={onOpenMarketplace}
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</button>
)
: (
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
className="inline-flex items-center system-sm-medium text-text-accent"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
</Link>
)}
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}
{
!isAllPluginsLoading && !collapse && (
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton={canInstallPlugin}
cardContainerClassName="grid grid-cols-3 gap-2"
cardRender={cardRender}
emptyClassName="h-auto"
/>
)
}
</div>
{!collapse && isAllPluginsLoading && <Loading type="area" />}
{
!isAllPluginsLoading && !collapse && (
<List
marketplaceCollections={[]}
marketplaceCollectionPluginsMap={{}}
plugins={allPlugins}
showInstallButton={canInstallPlugin}
cardContainerClassName="grid grid-cols-3 gap-2"
cardRender={cardRender}
emptyClassName="h-auto"
/>
)
}
</div>
)
}

View File

@ -4,7 +4,6 @@ import type { PluginDetail } from '@/app/components/plugins/types'
import { Trans, useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { PluginSource } from '@/app/components/plugins/types'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import { IS_CLOUD_EDITION } from '@/config'
import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
@ -61,15 +60,13 @@ function ModelProviderListSkeleton() {
function EmptyProviderState({
enableMarketplace,
stepByStepTourTarget,
}: {
enableMarketplace: boolean
stepByStepTourTarget?: string
}) {
const { t } = useTranslation()
return (
<div className="rounded-[10px] bg-workflow-process-bg p-4" data-step-by-step-tour-target={stepByStepTourTarget}>
<div className="rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
<span aria-hidden className="i-ri-brain-2-line size-5 text-text-primary" />
</div>
@ -86,9 +83,7 @@ function EmptyProviderState({
href="#model-provider-marketplace"
aria-label={t('marketplace.difyMarketplace', { ns: 'plugin' })}
className="system-xs-medium text-text-accent hover:underline"
>
{t('mainNav.marketplace', { ns: 'common' })}
</a>
/>
),
}}
/>
@ -100,7 +95,6 @@ function EmptyProviderState({
}
type ProviderCardListProps = {
firstCardTarget?: string
providers: ModelProvider[]
pluginDetailMap: Map<string, PluginDetail>
notConfigured?: boolean
@ -111,7 +105,6 @@ function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<strin
}
function ProviderCardList({
firstCardTarget,
providers,
pluginDetailMap,
notConfigured,
@ -129,20 +122,16 @@ function ProviderCardList({
return (
<div className="relative flex flex-col gap-2">
{sortedProviders.map((provider, index) => {
{sortedProviders.map((provider) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
return (
<div
<ProviderAddedCard
key={provider.provider}
data-step-by-step-tour-target={index === 0 ? firstCardTarget : undefined}
>
<ProviderAddedCard
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
</div>
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
)
})}
</div>
@ -168,7 +157,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
return (
<div className="flex flex-col gap-2">
{IS_CLOUD_EDITION && (
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderCredits}>
<div>
<QuotaPanel providers={providers} />
</div>
)}
@ -177,15 +166,9 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<ModelProviderListSkeleton />
</div>
)}
{showEmptyProvider && (
<EmptyProviderState
enableMarketplace={enableMarketplace}
stepByStepTourTarget={!showConfiguredProviders && !showNotConfiguredProviders ? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction : undefined}
/>
)}
{showEmptyProvider && <EmptyProviderState enableMarketplace={enableMarketplace} />}
{showConfiguredProviders && (
<ProviderCardList
firstCardTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction}
providers={filteredConfiguredProviders}
pluginDetailMap={pluginDetailMap}
/>
@ -194,7 +177,6 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<div className="flex flex-col gap-2 pt-2">
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<ProviderCardList
firstCardTarget={!showConfiguredProviders ? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction : undefined}
providers={filteredNotConfiguredProviders}
notConfigured
pluginDetailMap={pluginDetailMap}
@ -207,7 +189,6 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
providers={providers}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
/>
</div>
)}

Some files were not shown because too many files have changed in this diff Show More