diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 2aacf720c15..12971b9b8ca 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from enum import StrEnum from typing import Literal @@ -1138,6 +1138,16 @@ 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): """ diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index d8f096bc4d9..34869760f20 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -40,6 +40,7 @@ from . import ( init_validate, knowledge_fs_proxy, notification, + onboarding, ping, setup, spec, @@ -209,6 +210,7 @@ __all__ = [ "notification", "oauth", "oauth_server", + "onboarding", "ops_trace", "parameter", "ping", diff --git a/api/controllers/console/onboarding.py b/api/controllers/console/onboarding.py new file mode 100644 index 00000000000..7458bd46a3d --- /dev/null +++ b/api/controllers/console/onboarding.py @@ -0,0 +1,106 @@ +"""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): + 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, + ), + ) diff --git a/api/migrations/versions/2026_06_29_1200-b8c9d0e1f2a3_add_step_by_step_tour_state.py b/api/migrations/versions/2026_06_29_1200-b8c9d0e1f2a3_add_step_by_step_tour_state.py new file mode 100644 index 00000000000..b44609b4f7a --- /dev/null +++ b/api/migrations/versions/2026_06_29_1200-b8c9d0e1f2a3_add_step_by_step_tour_state.py @@ -0,0 +1,39 @@ +"""add step by step tour state + +Revision ID: b8c9d0e1f2a3 +Revises: 3c9f8e2a1d7b +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 = "b8c9d0e1f2a3" +down_revision = "3c9f8e2a1d7b" +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") diff --git a/api/models/__init__.py b/api/models/__init__.py index d29f75ac4bb..b4c1362b414 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -101,6 +101,7 @@ from .model import ( UploadFile, ) from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken +from .onboarding import AccountStepByStepTourState from .provider import ( LoadBalancingModelConfig, Provider, @@ -155,6 +156,7 @@ __all__ = [ "Account", "AccountIntegrate", "AccountStatus", + "AccountStepByStepTourState", "AccountTrialAppRecord", "Agent", "AgentConfigDraft", diff --git a/api/models/onboarding.py b/api/models/onboarding.py new file mode 100644 index 00000000000..3495d91274e --- /dev/null +++ b/api/models/onboarding.py @@ -0,0 +1,59 @@ +"""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(), + ) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index d1760c1c762..94bd31486ce 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -7787,6 +7787,30 @@ Initiate OAuth login process | ---- | ----------- | ------ | | 200 | Success | **application/json**: [OAuthProviderTokenResponse](#oauthprovidertokenresponse)
| +### [GET] /onboarding/step-by-step-tour/state +Get account-level Step-by-step Tour state + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)
| + +### [PATCH] /onboarding/step-by-step-tour/state +Update account-level Step-by-step Tour state + +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **application/json**: [StepByStepTourStatePatchPayload](#stepbysteptourstatepatchpayload)
| + +#### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)
| + ### [DELETE] /rag/pipeline/customized/templates/{template_id} #### Parameters @@ -21788,6 +21812,24 @@ Query parameters for listing snippet published workflows. | paused | integer | | Yes | | success | integer | | Yes | +#### StepByStepTourStatePatchPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| action | string,
**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action
*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes | +| task_id | string | Task ID for task actions | No | + +#### StepByStepTourStateResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| completed_task_ids | [ string,
**Available values:** "home", "integration", "knowledge", "studio" ] | | No | +| first_workspace_id | string | | No | +| manually_disabled_workspace_ids | [ string ] | | No | +| manually_enabled_workspace_ids | [ string ] | | No | +| skipped | boolean | | No | +| updated_at | string | | No | + #### Storage | Name | Type | Description | Required | @@ -21941,6 +21983,7 @@ Model class for provider system configuration response. | enable_learn_app | boolean,
**Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | +| enable_step_by_step_tour | boolean | | Yes | | enable_trial_app | boolean | | Yes | | is_allow_create_workspace | boolean | | Yes | | is_allow_register | boolean | | Yes | diff --git a/api/openapi/markdown/web-openapi.md b/api/openapi/markdown/web-openapi.md index cfd1da2a0ce..521c126d5b0 100644 --- a/api/openapi/markdown/web-openapi.md +++ b/api/openapi/markdown/web-openapi.md @@ -1577,6 +1577,7 @@ Default configuration for form inputs. | enable_learn_app | boolean,
**Default:** true | | Yes | | enable_marketplace | boolean | | Yes | | enable_social_oauth_login | boolean | | Yes | +| enable_step_by_step_tour | boolean | | Yes | | enable_trial_app | boolean | | Yes | | is_allow_create_workspace | boolean | | Yes | | is_allow_register | boolean | | Yes | diff --git a/api/services/feature_service.py b/api/services/feature_service.py index fb4a4844c91..f8d69073458 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -183,6 +183,7 @@ 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,6 +286,7 @@ 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]: diff --git a/api/services/step_by_step_tour_service.py b/api/services/step_by_step_tour_service.py new file mode 100644 index 00000000000..b01d59c1acc --- /dev/null +++ b/api/services/step_by_step_tour_service.py @@ -0,0 +1,221 @@ +"""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): + 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(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(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, + *, + state: AccountStepByStepTourState | None, + ) -> StepByStepTourStateResponse: + if state is None: + return { + "first_workspace_id": None, + "skipped": False, + "completed_task_ids": [], + "manually_enabled_workspace_ids": [], + "manually_disabled_workspace_ids": [], + "updated_at": None, + } + + return { + "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": state.updated_at, + } + + @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 diff --git a/api/tests/unit_tests/controllers/console/test_onboarding.py b/api/tests/unit_tests/controllers/console/test_onboarding.py new file mode 100644 index 00000000000..c2afd7f7422 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/test_onboarding.py @@ -0,0 +1,99 @@ +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 { + "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 == { + "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"}) diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py index ed64c4d08dc..61616b84356 100644 --- a/api/tests/unit_tests/services/test_feature_service_learn_app.py +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -6,6 +6,7 @@ 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]) @@ -15,3 +16,12 @@ 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 diff --git a/api/tests/unit_tests/services/test_step_by_step_tour_service.py b/api/tests/unit_tests/services/test_step_by_step_tour_service.py new file mode 100644 index 00000000000..de08cc596fd --- /dev/null +++ b/api/tests/unit_tests/services/test_step_by_step_tour_service.py @@ -0,0 +1,238 @@ +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["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 == { + "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["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["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 diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c3253808473..20df68ca17e 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -807,11 +807,6 @@ "count": 1 } }, - "web/app/components/apps/starred-app-card.tsx": { - "jsx_a11y/no-noninteractive-element-to-interactive-role": { - "count": 1 - } - }, "web/app/components/base/action-button/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -3936,14 +3931,6 @@ "count": 3 } }, - "web/app/components/tools/tool-provider-grid.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/tools/types.ts": { "erasable-syntax-only/enums": { "count": 4 diff --git a/packages/contracts/generated/api/console/onboarding/orpc.gen.ts b/packages/contracts/generated/api/console/onboarding/orpc.gen.ts new file mode 100644 index 00000000000..e3d9b96f9ec --- /dev/null +++ b/packages/contracts/generated/api/console/onboarding/orpc.gen.ts @@ -0,0 +1,55 @@ +// 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, +} diff --git a/packages/contracts/generated/api/console/onboarding/types.gen.ts b/packages/contracts/generated/api/console/onboarding/types.gen.ts new file mode 100644 index 00000000000..4654514e7e8 --- /dev/null +++ b/packages/contracts/generated/api/console/onboarding/types.gen.ts @@ -0,0 +1,52 @@ +// 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'> + first_workspace_id?: string | null + manually_disabled_workspace_ids?: Array + manually_enabled_workspace_ids?: Array + 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] diff --git a/packages/contracts/generated/api/console/onboarding/zod.gen.ts b/packages/contracts/generated/api/console/onboarding/zod.gen.ts new file mode 100644 index 00000000000..23c7a25ae8d --- /dev/null +++ b/packages/contracts/generated/api/console/onboarding/zod.gen.ts @@ -0,0 +1,41 @@ +// 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(), + 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 diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index 5c5e288753b..eda2bbfcefd 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -48,6 +48,7 @@ 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 })), ping: () => import('./ping/orpc.gen').then(({ ping }) => ({ ping })), rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })), refreshToken: () => diff --git a/packages/contracts/generated/api/console/router.gen.ts b/packages/contracts/generated/api/console/router.gen.ts index d8591d31833..a28155f4eea 100644 --- a/packages/contracts/generated/api/console/router.gen.ts +++ b/packages/contracts/generated/api/console/router.gen.ts @@ -32,6 +32,7 @@ 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 { ping } from './ping/orpc.gen' import { rag } from './rag/orpc.gen' import { refreshToken } from './refresh-token/orpc.gen' @@ -88,6 +89,7 @@ const communityContract = { notification, notion, oauth, + onboarding, ping, rag, refreshToken, diff --git a/packages/contracts/generated/api/console/system-features/types.gen.ts b/packages/contracts/generated/api/console/system-features/types.gen.ts index 17c5b63f2aa..eaf6a11fb87 100644 --- a/packages/contracts/generated/api/console/system-features/types.gen.ts +++ b/packages/contracts/generated/api/console/system-features/types.gen.ts @@ -16,6 +16,7 @@ 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 diff --git a/packages/contracts/generated/api/console/system-features/zod.gen.ts b/packages/contracts/generated/api/console/system-features/zod.gen.ts index 4fda744ead4..402a485925d 100644 --- a/packages/contracts/generated/api/console/system-features/zod.gen.ts +++ b/packages/contracts/generated/api/console/system-features/zod.gen.ts @@ -113,6 +113,7 @@ 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), diff --git a/packages/contracts/generated/api/web/types.gen.ts b/packages/contracts/generated/api/web/types.gen.ts index 10c5c31e097..ad578e8381a 100644 --- a/packages/contracts/generated/api/web/types.gen.ts +++ b/packages/contracts/generated/api/web/types.gen.ts @@ -520,6 +520,7 @@ 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 diff --git a/packages/contracts/generated/api/web/zod.gen.ts b/packages/contracts/generated/api/web/zod.gen.ts index 7d46f57eea3..093722ed9cc 100644 --- a/packages/contracts/generated/api/web/zod.gen.ts +++ b/packages/contracts/generated/api/web/zod.gen.ts @@ -791,6 +791,7 @@ 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), diff --git a/packages/iconify-collections/assets/vender/line/education/lesson-open-01.svg b/packages/iconify-collections/assets/vender/line/education/lesson-open-01.svg new file mode 100644 index 00000000000..9e0021a2db4 --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/education/lesson-open-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index f1aa8be7d73..b100ce8257a 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -1,6 +1,6 @@ { "prefix": "custom-vender", - "lastModified": 1782365697, + "lastModified": 1782516559, "icons": { "agent-v2-access-point": { "body": "", @@ -451,6 +451,9 @@ "width": 12, "height": 12 }, + "line-education-lesson-open-01": { + "body": "" + }, "line-files-copy": { "body": "" }, diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 57770b38ddc..fdc5ad8ce64 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 330, + "total": 331, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/app/components/app/create-app-dropdown.tsx b/web/app/components/app/create-app-dropdown.tsx index 99be41f1c4c..87f0a8f7f9a 100644 --- a/web/app/components/app/create-app-dropdown.tsx +++ b/web/app/components/app/create-app-dropdown.tsx @@ -9,25 +9,39 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { useTranslation } from 'react-i18next' +import { + getStepByStepTourDropdownMenuContentProps, + useStepByStepTourControlledDropdown, +} from '@/app/components/step-by-step-tour/dropdown-menu' type CreateAppDropdownProps = { onCreateBlank: () => void onCreateTemplate?: () => void onImportDSL: () => void + stepByStepTourControlledOpen?: boolean + stepByStepTourTarget?: string + stepByStepTourHighlightPart?: string } export function CreateAppDropdown({ onCreateBlank, onCreateTemplate, onImportDSL, + stepByStepTourControlledOpen, + stepByStepTourTarget, + stepByStepTourHighlightPart, }: CreateAppDropdownProps) { const { t } = useTranslation() + const menu = useStepByStepTourControlledDropdown({ + controlledOpen: stepByStepTourControlledOpen, + }) return ( - + } /> - +
ProviderContextState>() const buildModalContext = (): ModalContextState => ({ + hasBlockingModalOpen: false, setShowAccountSettingModal: mockSetShowAccountSettingModal, setShowModerationSettingModal: vi.fn(), setShowExternalDataToolModal: vi.fn(), diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index ba2747392c9..0dba9a897f7 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -2,6 +2,7 @@ import type { Mock } from 'vitest' import type { App } from '@/types/app' import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +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' @@ -407,21 +408,28 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => { children, className, popupClassName, + popupProps, + positionerProps, }: { children: React.ReactNode className?: string popupClassName?: string + popupProps?: React.HTMLAttributes + positionerProps?: React.HTMLAttributes }) => { const { isOpen } = useDropdownMenuContext() if (!isOpen) return null return ( -
- {children} +
+
+ {children} +
) }, @@ -1885,6 +1893,29 @@ describe('AppCard', () => { expect(screen.getByText('common.operation.delete')).toBeInTheDocument() }) }) + + it('should render the tour-controlled operations menu as presentation only', async () => { + render( + , + ) + + 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') + }) }) describe('Open in Explore - No App Found', () => { diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 4886e4e1ece..c614bff67ee 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,10 +1,18 @@ import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen' +import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { App } from '@/models/explore' import type { TryAppSelection } from '@/types/try-app' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { createStore, Provider as JotaiProvider } from 'jotai' import * as React from 'react' +import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state' +import { + getStepByStepTourTargetSelector, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import List from '../list' @@ -31,6 +39,7 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() => onlineUsersMap: {}, })), ) +let stepByStepTourSessionState: StepByStepTourSessionState = {} const mockLearnDifyApp = vi.hoisted( () => @@ -118,7 +127,6 @@ vi.mock('@/context/permission-state', async () => { workspacePermissionKeys: mockWorkspacePermissionKeys, })) }) - const mockOnPlanInfoChanged = vi.fn() vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ @@ -401,11 +409,34 @@ vi.mock('@/next/dynamic', () => ({ })) vi.mock('../app-card', () => ({ - AppCard: ({ app }: { app: { id: string; name: string } }) => { + 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}`, role: 'article' }, + { + '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', + }), ) }, AppCardActionBar: ({ app, onRefresh }: { app: { id: string }; onRefresh?: () => void }) => { @@ -425,10 +456,14 @@ vi.mock('../app-card', () => ({ })) vi.mock('../empty', () => ({ - default: () => { + default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => { return React.createElement( 'div', - { 'data-testid': 'empty-state', role: 'status' }, + { + 'data-testid': 'empty-state', + 'data-step-by-step-tour-target': stepByStepTourTarget, + role: 'status', + }, 'No apps found', ) }, @@ -497,12 +532,21 @@ const renderList = (searchParams = '', options: RenderListOptions = {}) => { const { wrapper: ConsoleQueryWrapper } = createConsoleQueryWrapper({ systemFeatures: { branding: { enabled: false }, ...options.systemFeatures }, }) - return renderWithNuqs( + const store = createStore() + seedRegisteredConsoleStateFixture(store) + store.set(stepByStepTourSessionAtom, stepByStepTourSessionState) + const rendered = renderWithNuqs( - + + + , { searchParams }, ) + return rendered } type AppListInfiniteOptions = { @@ -526,9 +570,25 @@ const openAppSortSelect = async (user = userEvent.setup()) => { return user } +const setActiveStudioStepByStepTour = ( + activeGuideIndex: number, + activeGuideGroup: + | 'studioWithApps' + | 'studioNoCreateEmpty' + | 'studioNoCreateWithApps' + | undefined = 'studioWithApps', +) => { + stepByStepTourSessionState = { + activeTaskId: 'studio', + activeGuideGroup, + activeGuideIndex, + } +} + describe('List', () => { beforeEach(() => { vi.clearAllMocks() + stepByStepTourSessionState = {} mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false) mockWorkspacePermissionKeys = ['app.create_and_management'] mockDragging = false @@ -600,6 +660,29 @@ describe('List', () => { expect(screen.getByRole('button', { name: 'common.operation.create' }))!.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() @@ -690,6 +773,148 @@ 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() @@ -711,6 +936,28 @@ 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', () => { diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 61df5be1490..b38ec6e1741 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -36,6 +36,10 @@ 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 { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' @@ -105,6 +109,10 @@ type AppCardProps = { onlineUsers?: WorkflowOnlineUser[] onRefresh?: () => void onOpenTagManagement?: () => void + stepByStepTourActionMenuOpen?: boolean + stepByStepTourCardTarget?: string + stepByStepTourCardHighlightPart?: string + stepByStepTourActionMenuHighlightPart?: string } type AppAccessModeIconProps = { @@ -816,6 +824,10 @@ export function AppCard({ onlineUsers = [], onRefresh, onOpenTagManagement = () => {}, + stepByStepTourActionMenuOpen = false, + stepByStepTourCardTarget, + stepByStepTourCardHighlightPart, + stepByStepTourActionMenuHighlightPart, }: AppCardProps) { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) @@ -831,7 +843,12 @@ export function AppCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) - const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) + const operationsMenu = useStepByStepTourControlledDropdown({ + allowTriggerCloseWhileControlled: false, + controlledOpen: stepByStepTourActionMenuOpen, + }) + const isOperationsMenuOpen = operationsMenu.open + const setIsOperationsMenuOpen = operationsMenu.onOpenChange const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation() @@ -1204,6 +1221,8 @@ export function AppCard({ 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} @@ -1215,6 +1234,8 @@ export function AppCard({ 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} @@ -1297,6 +1318,10 @@ export function AppCard({ placement="bottom-end" sideOffset={4} popupClassName={operationsMenuWidthClassName} + {...getStepByStepTourDropdownMenuContentProps({ + highlightPart: stepByStepTourActionMenuHighlightPart, + interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive', + })} > {systemFeatures.webapp_auth.enabled ? ( void onOpenTagManagement: () => void showCreateButton: boolean + stepByStepTourCreateMenuOpen?: boolean + stepByStepTourCreateMenuTarget?: string + stepByStepTourCreateMenuHighlightPart?: string } export function AppListHeaderFilters({ @@ -48,9 +51,11 @@ export function AppListHeaderFilters({ onImportDSL, onOpenTagManagement, showCreateButton, + stepByStepTourCreateMenuOpen, + stepByStepTourCreateMenuTarget, + stepByStepTourCreateMenuHighlightPart, }: AppListHeaderFiltersProps) { const { t } = useTranslation() - return (
@@ -85,6 +90,9 @@ export function AppListHeaderFilters({ onCreateBlank={onCreateBlank} onCreateTemplate={onCreateTemplate} onImportDSL={onImportDSL} + stepByStepTourControlledOpen={stepByStepTourCreateMenuOpen} + stepByStepTourTarget={stepByStepTourCreateMenuTarget} + stepByStepTourHighlightPart={stepByStepTourCreateMenuHighlightPart} /> )}
diff --git a/web/app/components/apps/empty.tsx b/web/app/components/apps/empty.tsx index 2d880ff2321..93fa7ce0322 100644 --- a/web/app/components/apps/empty.tsx +++ b/web/app/components/apps/empty.tsx @@ -4,12 +4,20 @@ import FilterEmptyState from '@/app/components/base/filter-empty-state' type EmptyProps = { message?: string + stepByStepTourTarget?: string } -const Empty = ({ message }: EmptyProps) => { +const Empty = ({ message, stepByStepTourTarget }: EmptyProps) => { const { t } = useTranslation() - return $['filterEmpty.noApps'], { ns: 'app' })} /> + return ( + $['filterEmpty.noApps'], { ns: 'app' })} + contentDataAttributes={ + stepByStepTourTarget ? { 'data-step-by-step-tour-target': stepByStepTourTarget } : undefined + } + /> + ) } export default React.memo(Empty) diff --git a/web/app/components/apps/first-empty-state/action-card.tsx b/web/app/components/apps/first-empty-state/action-card.tsx index 5d44a715d41..71907e878db 100644 --- a/web/app/components/apps/first-empty-state/action-card.tsx +++ b/web/app/components/apps/first-empty-state/action-card.tsx @@ -8,6 +8,7 @@ type VisualStyle = 'default' | 'compact' | 'list' type BaseProps = { badge?: string badgeVariant?: 'basic' | 'advanced' + stepByStepTourTarget?: string description: string icon: ReactNode title: string @@ -144,7 +145,11 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) { if (props.href) { return ( - + +
{showLearnDify && ( - $['firstEmpty.learnDifyTitle'], { ns: 'app' })} - /> +
+ $['firstEmpty.learnDifyTitle'], { ns: 'app' })} + /> +
)}
) diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 6dbd16eb792..31ec8ba35b9 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -11,10 +11,20 @@ import { useSuspenseQuery, } from '@tanstack/react-query' import { useDebounce } from 'ahooks' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNeedRefreshAppList } from '@/app/components/apps/storage' +import { + activeStepByStepTourGuideGroupAtom, + activeStepByStepTourGuideIndexAtom, + activeStepByStepTourTaskIdAtom, + resolveStepByStepTourGuideGroupAtom, +} from '@/app/components/step-by-step-tour/state' +import { + getStepByStepTourGuides, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -38,6 +48,7 @@ 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 type AppListSortBy = NonNullable @@ -71,6 +82,10 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [droppedDSLFile, setDroppedDSLFile] = useState() const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList() + const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) + const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) + const activeStepByStepTourGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) + const resolveStepByStepTourGuideGroup = useSetAtom(resolveStepByStepTourGuideGroupAtom) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') const handleDSLFileDropped = useCallback( @@ -224,6 +239,35 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro 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 = activeStepByStepTourGuideGroup ?? activeStudioGuideGroup + const activeStudioGuides = + activeStepByStepTourTaskId === 'studio' && effectiveActiveStudioGuideGroup + ? getStepByStepTourGuides('studio', effectiveActiveStudioGuideGroup) + : [] + const activeStudioGuide = activeStudioGuides[activeStepByStepTourGuideIndex ?? 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) }, [canCreateApp]) @@ -234,6 +278,24 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro if (canCreateApp) setShowCreateFromDSLModal(true) }, [canCreateApp]) + useEffect(() => { + if (activeStepByStepTourTaskId !== 'studio') return + if (!hasResolvedFirstPage || showSkeleton || !activeStudioGuideGroup) return + if (activeStepByStepTourGuideGroup === activeStudioGuideGroup) return + + resolveStepByStepTourGuideGroup({ + taskId: 'studio', + guideGroup: activeStudioGuideGroup, + }) + }, [ + activeStepByStepTourGuideGroup, + activeStepByStepTourTaskId, + activeStudioGuideGroup, + hasResolvedFirstPage, + resolveStepByStepTourGuideGroup, + showSkeleton, + ]) + return ( <>
setShowTagManagementModal(true)} showCreateButton={canCreateApp} + stepByStepTourCreateMenuOpen={ + activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined + } + stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate} + stepByStepTourCreateMenuHighlightPart={ + STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu + } /> {showFirstEmptyState ? ( @@ -283,7 +352,25 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro ) : ( <> {starredApps.length > 0 && ( - + )}
) : hasAnyApp ? ( - apps.map((app) => ( + apps.map((app, index) => ( 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 + } /> )) ) : ( - + )} {isFetchingNextPage && }
diff --git a/web/app/components/apps/starred-app-card.tsx b/web/app/components/apps/starred-app-card.tsx index 35368179ffe..777f02209ac 100644 --- a/web/app/components/apps/starred-app-card.tsx +++ b/web/app/components/apps/starred-app-card.tsx @@ -22,9 +22,16 @@ import { AppCardActionBar } from './app-card' type StarredAppCardProps = { app: App onRefresh?: () => void + stepByStepTourCardTarget?: string + stepByStepTourCardHighlightPart?: string } -export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { +export function StarredAppCard({ + app, + onRefresh, + stepByStepTourCardTarget, + stepByStepTourCardHighlightPart, +}: StarredAppCardProps) { const { t } = useTranslation() const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) @@ -96,19 +103,26 @@ export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) { return (
{isPreviewOnly ? ( -
{cardContent} -
+
) : ( - + {cardContent} )} diff --git a/web/app/components/apps/starred-app-list.tsx b/web/app/components/apps/starred-app-list.tsx index c1670062cea..c3994f7af8c 100644 --- a/web/app/components/apps/starred-app-list.tsx +++ b/web/app/components/apps/starred-app-list.tsx @@ -8,6 +8,9 @@ import { StarredAppCard } from './starred-app-card' type StarredAppListProps = { apps: App[] onRefresh?: () => void + stepByStepTourCardTarget?: string + stepByStepTourCardHighlightPart?: string + stepByStepTourHighlightedCardCount?: number } function SectionDivider({ label }: { label: string }) { @@ -20,7 +23,13 @@ function SectionDivider({ label }: { label: string }) { ) } -export function StarredAppList({ apps, onRefresh }: StarredAppListProps) { +export function StarredAppList({ + apps, + onRefresh, + stepByStepTourCardTarget, + stepByStepTourCardHighlightPart, + stepByStepTourHighlightedCardCount = 0, +}: StarredAppListProps) { const { t } = useTranslation() if (apps.length === 0) return null @@ -29,8 +38,18 @@ export function StarredAppList({ apps, onRefresh }: StarredAppListProps) { <> $['studio.starred'], { ns: 'app' })} />
- {apps.map((app) => ( - + {apps.map((app, index) => ( + ))}
$['studio.allApps'], { ns: 'app' })} /> diff --git a/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx index 4c77e735383..681516520b5 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/agent-roster-response-content.spec.tsx @@ -548,7 +548,7 @@ describe('AgentRosterResponseContent', () => { render() const processToggle = screen.getByRole('button', { - name: 'Thinking · {{count}}m{{count}}s', + name: 'Thinking · 1m6s', }) expect(processToggle).toHaveAttribute('aria-expanded', 'false') expect(screen.queryByText('raw process detail')).not.toBeInTheDocument() diff --git a/web/app/components/base/filter-empty-state/index.tsx b/web/app/components/base/filter-empty-state/index.tsx index 1881981406b..5aa03e23be1 100644 --- a/web/app/components/base/filter-empty-state/index.tsx +++ b/web/app/components/base/filter-empty-state/index.tsx @@ -1,14 +1,19 @@ 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 -const FilterEmptyState = ({ title, className }: FilterEmptyStateProps) => { +const FilterEmptyState = ({ title, className, contentDataAttributes }: FilterEmptyStateProps) => { return (
{ ))}
-
+
diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx index 68d42a340bd..3e5e7e3e697 100644 --- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -3,6 +3,7 @@ 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' @@ -69,8 +70,27 @@ vi.mock('../../rename-modal', () => ({ })) vi.mock('../dataset-card', () => ({ - default: ({ dataset }: { dataset: DataSet }) => ( -
{dataset.name}
+ default: ({ + dataset, + stepByStepTourActionMenuHighlightPart, + stepByStepTourActionMenuOpen, + stepByStepTourCardTarget, + }: { + dataset: DataSet + stepByStepTourActionMenuHighlightPart?: string + stepByStepTourActionMenuOpen?: boolean + stepByStepTourCardTarget?: string + }) => ( +
+ {dataset.name} + {stepByStepTourCardTarget} + + {String(stepByStepTourActionMenuOpen)} + + + {stepByStepTourActionMenuHighlightPart} + +
), })) @@ -186,6 +206,30 @@ describe('Datasets', () => { expect(screen.getByText('Dataset 2')).toBeInTheDocument() }) + it('should pass step-by-step tour targets to the first dataset card only', () => { + render( + , + ) + + 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( ({ - Button: ({ children }: { children: React.ReactNode }) => ( - + Button: ({ children, className, ...props }: React.ButtonHTMLAttributes) => ( + ), })) vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, - DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuContent: ({ + children, + popupProps, + positionerProps, + }: { + children: React.ReactNode + popupProps?: React.HTMLAttributes + positionerProps?: React.HTMLAttributes + }) => ( +
+
+ {children} +
+
+ ), DropdownMenuItem: ({ children, + className, onClick, }: { children: React.ReactNode + className?: string onClick?: () => void }) => ( - ), @@ -56,16 +75,32 @@ const defaultProps = { } describe('DatasetListHeader', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the updated create menu labels and pipeline icon', () => { + render() + + expect( + screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ }), + ).toBeInTheDocument() + + const menuItem = screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ }) + + expect(menuItem.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument() + }) + it('hides dataset creation actions without create permission', () => { render() expect( - screen.queryByRole('button', { name: /dataset\.firstEmpty\.createTitle/ }), + screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ }), ).not.toBeInTheDocument() expect( - screen.queryByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ }), + screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ }), ).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: /dataset\.connectDataset/ })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /dataset\.connectDataset/ })).toBeInTheDocument() }) it('hides the external API entry without external-connect permission', () => { @@ -89,4 +124,50 @@ describe('DatasetListHeader', () => { screen.queryByRole('button', { name: /common\.operation\.create/ }), ).not.toBeInTheDocument() }) + + it('exposes step-by-step tour targets for the create menu walkthrough', () => { + render( + , + ) + + 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( + , + ) + + fireEvent.click( + screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true }), + ) + + expect(defaultProps.onCreateDataset).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index f82619adb3e..0700aad29cc 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, 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 { render } from '@/test/console/render' import { DatasetACLPermission } from '@/utils/permission' @@ -134,10 +135,20 @@ vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({ ), })) vi.mock('../components/operations-dropdown', () => ({ - default: ({ openAccessConfig }: { openAccessConfig?: () => void }) => ( + default: ({ + openAccessConfig, + stepByStepTourHighlightPart, + stepByStepTourOpen, + }: { + openAccessConfig?: () => void + stepByStepTourHighlightPart?: string + stepByStepTourOpen?: boolean + }) => (
), })) @@ -178,6 +189,37 @@ 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( + , + ) + + 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', () => { diff --git a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx index 17a919610be..bd486ac93fa 100644 --- a/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/components/__tests__/operations-dropdown.spec.tsx @@ -1,5 +1,5 @@ import type { DataSet } from '@/models/datasets' -import { fireEvent, screen } from '@testing-library/react' +import { createEvent, fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { IndexingType } from '@/app/components/datasets/create/step-two' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' @@ -13,6 +13,7 @@ const mockConsoleState = vi.hoisted(() => ({ })) let mockIsRbacEnabled = true +const noopKeyboardHandler = () => {} const render = (ui: Parameters[0]) => renderWithConsoleQuery(ui, { @@ -219,6 +220,72 @@ describe('OperationsDropdown', () => { expect(onOutsideClick).toHaveBeenCalledTimes(1) }) + it('should prevent the card click default behavior when opening the menu', () => { + render() + + 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( +
+ +
, + ) + + 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( + , + ) + + 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( + , + ) + + 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() diff --git a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx index 7df72adca88..84374a1634d 100644 --- a/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx +++ b/web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx @@ -7,6 +7,10 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { useAtomValue } from 'jotai' import * as React from 'react' +import { + getStepByStepTourDropdownMenuContentProps, + useStepByStepTourControlledDropdown, +} from '@/app/components/step-by-step-tour/dropdown-menu' import { userProfileIdAtom } from '@/context/account-state' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { datasetRbacEnabledAtom } from '@/context/system-features-state' @@ -19,6 +23,8 @@ type OperationsDropdownProps = { handleExportPipeline: (include?: boolean) => void detectIsUsedByApp: () => void openAccessConfig: () => void + stepByStepTourHighlightPart?: string + stepByStepTourOpen?: boolean } const OperationsDropdown = ({ @@ -27,8 +33,15 @@ const OperationsDropdown = ({ handleExportPipeline, detectIsUsedByApp, openAccessConfig, + stepByStepTourHighlightPart, + stepByStepTourOpen, }: OperationsDropdownProps) => { - const [open, setOpen] = React.useState(false) + const operationsMenu = useStepByStepTourControlledDropdown({ + allowTriggerCloseWhileControlled: false, + controlledOpen: stepByStepTourOpen, + }) + const open = operationsMenu.open + const setOpen = operationsMenu.onOpenChange const currentUserId = useAtomValue(userProfileIdAtom) const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = useAtomValue(datasetRbacEnabledAtom) @@ -76,10 +89,21 @@ const OperationsDropdown = ({ 'data-popup-open:bg-state-base-hover', )} aria-label="Dataset operations" + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + }} > - + void onOpenTagManagement?: () => void + stepByStepTourActionMenuHighlightPart?: string + stepByStepTourActionMenuOpen?: boolean + stepByStepTourCardTarget?: string } -const DatasetCard = ({ dataset, onSuccess, onOpenTagManagement = () => {} }: DatasetCardProps) => { +const DatasetCard = ({ + dataset, + onSuccess, + onOpenTagManagement = () => {}, + stepByStepTourActionMenuHighlightPart, + stepByStepTourActionMenuOpen, + stepByStepTourCardTarget, +}: DatasetCardProps) => { const { t } = useTranslation() const { push } = useRouter() const currentUserId = useAtomValue(userProfileIdAtom) @@ -118,6 +128,7 @@ const DatasetCard = ({ dataset, onSuccess, onOpenTagManagement = () => {} }: Dat aria-label={isPreviewOnly ? dataset.name : undefined} className={cardClassName} data-disable-nprogress={true} + data-step-by-step-tour-target={stepByStepTourCardTarget} onClick={handleCardClick} onKeyDown={handlePreviewOnlyCardKeyDown} > @@ -141,6 +152,8 @@ const DatasetCard = ({ dataset, onSuccess, onOpenTagManagement = () => {} }: Dat handleExportPipeline={handleExportPipeline} detectIsUsedByApp={detectIsUsedByApp} openAccessConfig={openAccessConfig} + stepByStepTourHighlightPart={stepByStepTourActionMenuHighlightPart} + stepByStepTourOpen={stepByStepTourActionMenuOpen} /> )}
diff --git a/web/app/components/datasets/list/datasets.tsx b/web/app/components/datasets/list/datasets.tsx index a338cd9c865..c83df61bf85 100644 --- a/web/app/components/datasets/list/datasets.tsx +++ b/web/app/components/datasets/list/datasets.tsx @@ -19,6 +19,9 @@ type Props = Readonly<{ isPlaceholderData: ReturnType['isPlaceholderData'] emptyElement?: ReactNode onOpenTagManagement?: () => void + stepByStepTourActionMenuHighlightPart?: string + stepByStepTourActionMenuOpen?: boolean + stepByStepTourCardTarget?: string }> const Datasets = ({ @@ -31,6 +34,9 @@ const Datasets = ({ isPlaceholderData, emptyElement, onOpenTagManagement = () => {}, + stepByStepTourActionMenuHighlightPart, + stepByStepTourActionMenuOpen, + stepByStepTourCardTarget, }: Props) => { const { t } = useTranslation() const invalidDatasetList = useInvalidDatasetList() @@ -71,12 +77,19 @@ const Datasets = ({ {showDatasetSkeleton ? ( $.loading, { ns: 'common' })} /> ) : ( - datasets.map((dataset) => ( + datasets.map((dataset, index) => ( )) )} diff --git a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx index f10e48e02c0..0119cf02421 100644 --- a/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/first-empty-state/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ 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', () => ({ @@ -6,12 +7,9 @@ vi.mock('@/next/link', () => ({ children, href, className, - }: { - children: React.ReactNode - href: string - className?: string - }) => ( - + ...props + }: React.AnchorHTMLAttributes & { href: string }) => ( + {children} ), @@ -26,6 +24,49 @@ describe('DatasetFirstEmptyState', () => { ).toHaveAttribute('href', '/datasets/create-from-pipeline') }) + it('exposes step-by-step tour targets for the empty knowledge actions', () => { + render() + + 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( + , + ) + const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')).find( + (element) => element.className.includes('grid-rows-4'), + ) + + if (!placeholderGrid) + throw new Error('Expected dataset first empty state placeholder grid to render') + + expect(placeholderGrid).toHaveClass( + 'grid', + 'grid-cols-[repeat(auto-fill,minmax(296px,1fr))]', + 'grid-rows-4', + ) + expect(placeholderGrid).not.toHaveClass( + 'grid-cols-1', + 'sm:grid-cols-2', + 'lg:grid-cols-3', + 'xl:grid-cols-4', + ) + }) + it('only offers external connection without dataset creation permission', () => { render() diff --git a/web/app/components/datasets/list/first-empty-state/index.tsx b/web/app/components/datasets/list/first-empty-state/index.tsx index 69300f2d1e2..bfe508a0a20 100644 --- a/web/app/components/datasets/list/first-empty-state/index.tsx +++ b/web/app/components/datasets/list/first-empty-state/index.tsx @@ -1,6 +1,7 @@ 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 }, @@ -14,6 +15,7 @@ type EmptyCreateAction = { id: string title: string description: string + target: string } type DatasetFirstEmptyStateProps = { @@ -36,6 +38,7 @@ function DatasetFirstEmptyState({ id: 'create', title: t(($) => $['firstEmpty.createTitle'], { ns: 'dataset' }), description: t(($) => $['firstEmpty.createDescription'], { ns: 'dataset' }), + target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate, }, { href: '/datasets/create-from-pipeline', @@ -43,6 +46,7 @@ function DatasetFirstEmptyState({ id: 'pipeline', title: t(($) => $['firstEmpty.pipelineTitle'], { ns: 'dataset' }), description: t(($) => $['firstEmpty.pipelineDescription'], { ns: 'dataset' }), + target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline, }, ] : [] @@ -58,6 +62,7 @@ function DatasetFirstEmptyState({ id: 'connect', title: t(($) => $.connectDataset, { ns: 'dataset' }), description: t(($) => $['firstEmpty.connectDescription'], { ns: 'dataset' }), + target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect, } : undefined const hasActions = createActions.length > 0 || !!connectAction @@ -98,6 +103,7 @@ function DatasetFirstEmptyState({ description={action.description} href={action.href} icon={action.icon} + stepByStepTourTarget={action.target} title={action.title} visualStyle="list" /> @@ -118,6 +124,7 @@ function DatasetFirstEmptyState({ description={connectAction.description} href={connectAction.href} icon={connectAction.icon} + stepByStepTourTarget={connectAction.target} title={connectAction.title} visualStyle="list" /> diff --git a/web/app/components/datasets/list/header.tsx b/web/app/components/datasets/list/header.tsx index e6ea7ed73c8..582554ba2c6 100644 --- a/web/app/components/datasets/list/header.tsx +++ b/web/app/components/datasets/list/header.tsx @@ -11,6 +11,10 @@ import { 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' @@ -30,6 +34,9 @@ type Props = { onKeywordsChange: (value: string) => void onOpenTagManagement: () => void onTagsChange: (value: string[]) => void + stepByStepTourCreateMenuHighlightPart?: string + stepByStepTourCreateMenuOpen?: boolean + stepByStepTourCreateMenuTarget?: string } const DatasetListHeader = ({ @@ -48,9 +55,15 @@ const DatasetListHeader = ({ onKeywordsChange, onOpenTagManagement, onTagsChange, + stepByStepTourCreateMenuHighlightPart, + stepByStepTourCreateMenuOpen, + stepByStepTourCreateMenuTarget, }: Props) => { const { t } = useTranslation() const showCreateMenu = canCreateDataset || canConnectExternalDataset + const createMenu = useStepByStepTourControlledDropdown({ + controlledOpen: stepByStepTourCreateMenuOpen, + }) return (
@@ -103,10 +116,19 @@ const DatasetListHeader = ({
{showCreateMenu && ( - + + } /> - + {canCreateDataset && ( <> { hasResolvedFirstPage && !hasActiveFilters const showFilteredEmptyState = !hasAnyDataset && hasResolvedFirstPage && hasActiveFilters + const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) + const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) + const activeStepByStepTourGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom) + const resolveStepByStepTourGuideGroup = useSetAtom(resolveStepByStepTourGuideGroupAtom) + const activeKnowledgeGuideGroup = hasAnyDataset + ? 'knowledgeWithDatasets' + : showEmptyDataList && canCreateDataset && canConnectExternalDataset + ? 'knowledgeEmpty' + : undefined + const activeKnowledgeGuides = + activeStepByStepTourTaskId === 'knowledge' && activeKnowledgeGuideGroup + ? getStepByStepTourGuides('knowledge', activeKnowledgeGuideGroup) + : [] + const activeKnowledgeGuide = activeKnowledgeGuides[activeStepByStepTourGuideIndex ?? 0] + const shouldOpenStepByStepTourCreateMenu = + activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate + const shouldOpenStepByStepTourDatasetCardActionMenu = + activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard + + useEffect(() => { + if (activeStepByStepTourTaskId !== 'knowledge') return + if (!hasResolvedFirstPage || !activeKnowledgeGuideGroup) return + if (activeStepByStepTourGuideGroup === activeKnowledgeGuideGroup) return + + resolveStepByStepTourGuideGroup({ + taskId: 'knowledge', + guideGroup: activeKnowledgeGuideGroup, + }) + }, [ + activeStepByStepTourGuideGroup, + activeStepByStepTourTaskId, + activeKnowledgeGuideGroup, + hasResolvedFirstPage, + resolveStepByStepTourGuideGroup, + ]) return (
@@ -108,6 +153,13 @@ 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 + } /> { 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 + } /> { isLoading={datasetListQuery.isLoading} isPlaceholderData={datasetListQuery.isPlaceholderData} onOpenTagManagement={() => setShowTagManagementModal(true)} + stepByStepTourActionMenuHighlightPart={ + STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu + } + stepByStepTourActionMenuOpen={shouldOpenStepByStepTourDatasetCardActionMenu} + stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard} /> )} diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 03b3008ca03..8691f937487 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -1,21 +1,52 @@ +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 { StepByStepTourSessionState } 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 userEvent from '@testing-library/user-event' +import { createStore, Provider as JotaiProvider, useSetAtom } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { useHydrateAtoms } from 'jotai/utils' +import { + resetStepByStepTourSessionAtom, + stepByStepTourSessionAtom, +} from '@/app/components/step-by-step-tour/state' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '../../learn-dify/storage' import AppList from '../index' +type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean } + +function StepByStepTourSessionFixture({ + children, + initialState, +}: { + children: ReactNode + initialState: StepByStepTourSessionState +}) { + useHydrateAtoms([[stepByStepTourSessionAtom, initialState]], { + dangerouslyForceHydrate: true, + }) + + return children +} + const mockConsoleState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, + currentWorkspace: { id: 'workspace-1' }, workspacePermissionKeys: [] as string[], })) @@ -34,6 +65,116 @@ let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() const mockTrackCreateApp = vi.fn() +const mockTrackEvent = vi.hoisted(() => vi.fn()) +const mockStepByStepTour = vi.hoisted(() => { + const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const + const createState = ( + overrides: Partial = {}, + ): StepByStepTourStateResponse => ({ + 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 = {}, + ): StepByStepTourTestUiState => ({ + activeGuideGroup: undefined, + activeGuideIndex: undefined, + activeGuideIndexes: undefined, + activeTaskId: undefined, + minimized: false, + ...overrides, + }) + let state = createState() + let uiState: StepByStepTourTestUiState = createUiState() + const patchState = vi.fn( + async ({ + body, + }: { + body: StepByStepTourStatePatchPayload + }): Promise => { + 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 state() { + return state + }, + get uiState() { + return uiState + }, + patchState, + reset() { + state = createState() + uiState = createUiState() + patchState.mockClear() + }, + setState(overrides: Partial = {}) { + state = createState(overrides) + }, + setUiState(overrides: Partial = {}) { + uiState = createUiState(overrides) + }, + stateQueryKey, + } +}) const toastMocks = vi.hoisted(() => { const record = vi.fn() const api = Object.assign( @@ -77,6 +218,10 @@ vi.mock('@/service/explore', () => ({ fetchBanners: vi.fn(), })) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + vi.mock('@/service/client', () => ({ consoleClient: { systemFeatures: () => Promise.resolve({}), @@ -123,6 +268,25 @@ vi.mock('@/service/client', () => ({ }, }, }, + onboarding: { + stepByStepTour: { + state: { + get: { + queryKey: () => mockStepByStepTour.stateQueryKey, + queryOptions: () => ({ + queryKey: mockStepByStepTour.stateQueryKey, + queryFn: async () => mockStepByStepTour.state, + }), + }, + patch: { + mutationOptions: (options = {}) => ({ + mutationFn: mockStepByStepTour.patchState, + ...options, + }), + }, + }, + }, + }, explore: { apps: { get: { @@ -154,6 +318,10 @@ vi.mock('@/context/account-state', async () => { const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') return createAccountStateModuleMock(() => mockConsoleState) }) +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(() => mockConsoleState) +}) vi.mock('@/context/permission-state', async () => { const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') return createPermissionStateModuleMock(() => mockConsoleState) @@ -220,11 +388,27 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({ })) vi.mock('../../try-app', () => ({ - default: ({ onCreate, onClose }: { onCreate: () => void; onClose: () => void }) => ( + default: ({ + canCreate = true, + createButtonStepByStepTourTarget, + onCreate, + onClose, + }: { + canCreate?: boolean + createButtonStepByStepTourTarget?: string + onCreate: () => void + onClose: () => void + }) => (
- + {canCreate && ( + + )} @@ -329,6 +513,7 @@ const mockAppCreatePermission = (hasEditPermission: boolean) => { type RenderOptions = { enableExploreBanner?: boolean enableLearnApp?: boolean + extra?: ReactNode isCloudEdition?: boolean } @@ -354,10 +539,13 @@ 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 const jotaiStore = createStore() + seedRegisteredConsoleStateFixture(jotaiStore) + jotaiStore.set(queryClientAtom, queryClient) if (mockIsLoading) { mockFetchAppList.mockImplementation(() => new Promise(() => {})) @@ -378,7 +566,12 @@ const renderAppList = ( const Wrapped = ({ children }: { children: ReactNode }) => ( - {children} + + + {children} + {options.extra} + + ) const rendered = renderWithNuqs( @@ -390,6 +583,16 @@ const renderAppList = ( return { ...rendered, queryClient } } +function SkipHomeGuideProbe() { + const resetStepByStepTourSession = useSetAtom(resetStepByStepTourSessionAtom) + + return ( + + ) +} + describe('AppList', () => { beforeEach(() => { vi.useFakeTimers() @@ -418,6 +621,7 @@ describe('AppList', () => { mockIsLoading = false mockIsError = false mockConfig.isCloudEdition = false + mockStepByStepTour.reset() }) afterEach(() => { @@ -606,7 +810,12 @@ describe('AppList', () => { renderAppList() - expect(screen.getByRole('heading', { name: 'explore.learnDify.title' })).toBeInTheDocument() + 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.getByText('Learn Workflow Basics')).toBeInTheDocument() expect(screen.getByText('Learn Agent Basics')).toBeInTheDocument() expect( @@ -789,6 +998,7 @@ describe('AppList', () => { it('should open create flow from learn dify item card click', async () => { vi.useRealTimers() + const user = userEvent.setup() mockExploreData = { categories: ['Writing'], allList: [createApp()], @@ -807,12 +1017,314 @@ describe('AppList', () => { ) renderAppList(true) - fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' })) - fireEvent.click(await screen.findByTestId('confirm-create')) + await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) + await user.click(await screen.findByTestId('confirm-create')) 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() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + activeGuideIndexes: [0, 1], + minimized: true, + }) + + renderAppList(true, undefined, undefined, { isCloudEdition: true }) + + await user.click(await screen.findByRole('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, + ) + }) + + it('should close the Learn Dify detail when the home action guide is skipped', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + activeGuideIndexes: [0, 1], + minimized: true, + }) + + renderAppList(true, undefined, undefined, { + extra: , + isCloudEdition: true, + }) + + await user.click(await screen.findByRole('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 user.click(screen.getByTestId('skip-home-guide')) + + await waitFor(() => { + expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument() + }) + }) + + it('should complete the Learn Dify tour when a no-create user opens a lesson detail', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + minimized: true, + }) + + renderAppList(false, undefined, undefined, { isCloudEdition: true }) + + await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) + + expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument() + expect(screen.queryByTestId('try-app-create')).not.toBeInTheDocument() + await waitFor(() => { + expect(mockStepByStepTour.patchState.mock.calls.at(-1)?.[0]).toEqual({ + body: { + action: 'complete_task', + task_id: 'home', + }, + }) + }) + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'task_completed', + completed_task_count: 1, + home_outcome: 'lesson_opened', + permission_variant: 'no_create', + task_id: 'home', + task_total: 4, + }) + }) + + it('should complete the Learn Dify tour only after the app is created from details', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + activeGuideIndexes: [0, 1], + 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 }) + + await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) + await user.click(await screen.findByTestId('try-app-create')) + await user.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockStepByStepTour.patchState.mock.calls.at(-1)?.[0]).toEqual({ + body: { + action: 'complete_task', + task_id: 'home', + }, + }) + }) + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'task_completed', + completed_task_count: 1, + home_outcome: 'lesson_app_created', + permission_variant: 'full', + task_id: 'home', + task_total: 4, + }) + expect(mockHandleImportDSL).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + skipRedirectOnSuccess: true, + }), + ) + }) + + it('should clear the Learn Dify session and provenance when completion persistence fails', async () => { + vi.useRealTimers() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + activeGuideIndexes: [0, 1], + minimized: true, + }) + mockStepByStepTour.patchState.mockRejectedValueOnce(new Error('patch failed')) + ;(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 }) + + await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) + await user.click(await screen.findByTestId('try-app-create')) + await user.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(1) + }) + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'step_tour', + expect.objectContaining({ + action: 'task_completed', + home_outcome: 'lesson_app_created', + }), + ) + + await user.click(screen.getByRole('button', { name: 'Alpha' })) + await user.click(await screen.findByTestId('try-app-create')) + await user.click(await screen.findByTestId('confirm-create')) + + await waitFor(() => { + expect(mockHandleImportDSL).toHaveBeenCalledTimes(2) + }) + expect(mockHandleImportDSL).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ + skipRedirectOnSuccess: false, + }), + ) + expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(1) + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'step_tour', + expect.objectContaining({ + action: 'task_completed', + home_outcome: 'lesson_app_created', + }), + ) + }) + + it('should skip redirect after confirming a pending Learn Dify tour create', async () => { + vi.useRealTimers() + const user = userEvent.setup() + 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 }) + + await user.click(await screen.findByRole('button', { name: 'Learn Workflow Basics' })) + await user.click(await screen.findByTestId('try-app-create')) + await user.click(await screen.findByTestId('confirm-create')) + await user.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() + const user = userEvent.setup() + mockExploreData = { + categories: ['Writing'], + allList: [createApp()], + } + mockStepByStepTour.setUiState({ + activeTaskId: 'home', + activeGuideIndex: 0, + activeGuideIndexes: [0, 1], + minimized: true, + }) + + renderAppList(true, undefined, undefined, { isCloudEdition: true }) + + await user.click(await screen.findByRole('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, + ) + + await user.click(createFromDetailsButton) + expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument() + expect(createFromDetailsButton).not.toHaveAttribute('data-step-by-step-tour-target') + + await user.click(screen.getByTestId('hide-create')) + + await waitFor(() => { + expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument() + }) }) }) diff --git a/web/app/components/explore/app-list/explore-recommendations.tsx b/web/app/components/explore/app-list/explore-recommendations.tsx index 3cd29493989..8f8c9f91216 100644 --- a/web/app/components/explore/app-list/explore-recommendations.tsx +++ b/web/app/components/explore/app-list/explore-recommendations.tsx @@ -4,6 +4,7 @@ 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 }) @@ -11,18 +12,27 @@ 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 }) { return ( <> - + ) } diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index efded1d1599..277afe0eb67 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' +import type { 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' @@ -9,15 +10,29 @@ import type { TrackCreateAppParams } from '@/utils/create-app-tracking' import { cn } from '@langgenius/dify-ui/cn' import { queryOptions, useQueries, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useQueryState } from 'nuqs' import * as React from 'react' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' 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 { + getStepByStepTourPermissionVariant, + trackStepByStepTourEvent, +} from '@/app/components/step-by-step-tour/analytics' +import { + activeStepByStepTourGuideIndexAtom, + activeStepByStepTourTaskIdAtom, + advanceStepByStepTourGuideAtom, + completedStepByStepTourTaskIdsAtom, + completeStepByStepTourTaskAtom, + resetStepByStepTourSessionAtom, +} from '@/app/components/step-by-step-tour/state' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' +import { STEP_BY_STEP_TOUR_TASKS } from '@/app/components/step-by-step-tour/tasks' import { useLocale } from '@/context/i18n' import { workspacePermissionKeysAtom } from '@/context/permission-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -50,6 +65,7 @@ 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 ? { query: { language: locale } } : {} @@ -123,6 +139,41 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { }) const allCategoriesEn = t(($) => $['apps.allCategories'], { ns: 'explore', lng: 'en' }) const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management') + const activeStepByStepTourTaskId = useAtomValue(activeStepByStepTourTaskIdAtom) + const activeStepByStepTourGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom) + const completedStepByStepTourTaskIds = useAtomValue(completedStepByStepTourTaskIdsAtom) + const advanceStepByStepTourGuide = useSetAtom(advanceStepByStepTourGuideAtom) + const completeStepByStepTourTask = useSetAtom(completeStepByStepTourTaskAtom) + const resetStepByStepTourSession = useSetAtom(resetStepByStepTourSessionAtom) + const trackHomeTourCompleted = useCallback( + ( + completedTaskIds: StepByStepTourTaskId[], + homeOutcome: 'lesson_app_created' | 'lesson_opened', + ) => { + trackStepByStepTourEvent({ + action: 'task_completed', + task_id: HOME_STEP_BY_STEP_TOUR_TASK_ID, + completed_task_count: completedTaskIds.length, + home_outcome: homeOutcome, + 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) => completedTaskIds.includes(task.id))) { + trackStepByStepTourEvent({ + action: 'tour_completed', + completed_task_count: completedTaskIds.length, + task_total: STEP_BY_STEP_TOUR_TASKS.length, + }) + } + }, + [canCreateApp], + ) const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') @@ -186,21 +237,154 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { TrackCreateAppParams, 'source' | 'templateId' > | null>(null) + const isCurrentTryAppFromLearnDifyRef = useRef(false) + const shouldCompleteHomeTourOnCreateRef = useRef(false) + const isSubmittingHomeTourCreateRef = useRef(false) + const wasHomeTryAppCreateGuideActiveRef = useRef(false) const isShowTryAppPanel = !!currentTryApp - const hideTryAppPanel = useCallback(() => { + const shouldForceShowLearnDifyForTour = + activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && + !completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) && + (activeStepByStepTourGuideIndex ?? 0) === 0 + const abandonHomeTour = useCallback(() => { + if ( + activeStepByStepTourTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID || + completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) + ) { + return + } + + resetStepByStepTourSession() + }, [activeStepByStepTourTaskId, completedStepByStepTourTaskIds, resetStepByStepTourSession]) + + const completeHomeTourAfterCreate = useCallback(() => { + if (!shouldCompleteHomeTourOnCreateRef.current) return + + resetStepByStepTourSession() + isCurrentTryAppFromLearnDifyRef.current = false + shouldCompleteHomeTourOnCreateRef.current = false + isSubmittingHomeTourCreateRef.current = false + completeStepByStepTourTask({ + taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID, + onSuccess: (completedTaskIds) => { + trackHomeTourCompleted(completedTaskIds, 'lesson_app_created') + }, + }) + }, [completeStepByStepTourTask, resetStepByStepTourSession, trackHomeTourCompleted]) + + const completeHomeTourAfterOpenDetails = useCallback(() => { + if ( + activeStepByStepTourTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID || + completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) || + (activeStepByStepTourGuideIndex ?? 0) !== 0 + ) { + return + } + + resetStepByStepTourSession() + completeStepByStepTourTask({ + taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID, + onSuccess: (completedTaskIds) => trackHomeTourCompleted(completedTaskIds, 'lesson_opened'), + }) + }, [ + activeStepByStepTourGuideIndex, + activeStepByStepTourTaskId, + completedStepByStepTourTaskIds, + completeStepByStepTourTask, + resetStepByStepTourSession, + 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() + // oxlint-disable-next-line eslint-react/set-state-in-effect -- Also called from the tour-state sync effect when the Learn Dify action guide is skipped. + setCurrentTryApp(undefined) + }, [abandonHomeTourCreate]) + const homeTryAppCreateGuideActive = + activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && + activeStepByStepTourGuideIndex === 1 && + !completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) + useEffect(() => { + if (!isCurrentTryAppFromLearnDifyRef.current || !currentTryApp || isShowCreateModal) { + wasHomeTryAppCreateGuideActiveRef.current = false + return + } + + if (homeTryAppCreateGuideActive) { + wasHomeTryAppCreateGuideActiveRef.current = true + return + } + + if (wasHomeTryAppCreateGuideActiveRef.current) { + wasHomeTryAppCreateGuideActiveRef.current = false + hideTryAppPanel() + } + }, [currentTryApp, hideTryAppPanel, homeTryAppCreateGuideActive, isShowCreateModal]) const handleTryApp = useCallback((params: TryAppSelection) => { + isCurrentTryAppFromLearnDifyRef.current = false setCurrentTryApp(params) }, []) + const handleTryAppFromLearnDify = useCallback( + (params: TryAppSelection) => { + isCurrentTryAppFromLearnDifyRef.current = true + setCurrentTryApp(params) + + if ( + activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && + !completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) && + (activeStepByStepTourGuideIndex ?? 0) === 0 + ) { + if (!canCreateApp) { + completeHomeTourAfterOpenDetails() + isCurrentTryAppFromLearnDifyRef.current = false + return + } + + advanceStepByStepTourGuide({ + guideIndex: 1, + }) + } + }, + [ + activeStepByStepTourGuideIndex, + activeStepByStepTourTaskId, + advanceStepByStepTourGuide, + canCreateApp, + completedStepByStepTourTaskIds, + completeHomeTourAfterOpenDetails, + ], + ) const handleShowFromTryApp = useCallback(() => { setCurrApp(currentTryApp?.app || null) currentCreateAppTrackingRef.current = { source: 'explore_template_preview', templateId: currentTryApp?.appId || currentTryApp?.app.app_id, } + shouldCompleteHomeTourOnCreateRef.current = + isCurrentTryAppFromLearnDifyRef.current && + activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID && + !completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) && + activeStepByStepTourGuideIndex === 1 setIsShowCreateModal(true) - }, [currentTryApp?.app, currentTryApp?.appId]) + }, [ + activeStepByStepTourGuideIndex, + activeStepByStepTourTaskId, + completedStepByStepTourTaskIds, + currentTryApp?.app, + currentTryApp?.appId, + ]) const handleCreateFromLearnDify = useCallback((app: App) => { setCurrApp(app) setIsShowCreateModal(true) @@ -225,9 +409,15 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { currentCreateAppTrackingRef.current = null currentCreateAppModeRef.current = null }, []) + 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 @@ -244,27 +434,51 @@ 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() + } }, - [currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp], + [ + abandonHomeTourCreate, + completeHomeTourAfterCreate, + currApp?.app.id, + handleImportDSL, + hideTryAppPanel, + trackCurrentCreateApp, + ], ) const onConfirmDSL = useCallback(async () => { await handleImportDSLConfirm({ onSuccess: (response) => { trackCurrentCreateApp(response.app_mode) + completeHomeTourAfterCreate() onSuccess?.() }, + skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current, }) - }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + }, [completeHomeTourAfterCreate, handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + + const handleCancelDSLConfirm = useCallback(() => { + setShowDSLConfirmModal(false) + isSubmittingHomeTourCreateRef.current = false + abandonHomeTourCreate() + }, [abandonHomeTourCreate]) if (homeQueries.isAppListError) return null @@ -283,8 +497,9 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { void }) => { show={isShowCreateModal} onConfirm={onCreate} confirmDisabled={isFetching} - onHide={() => setIsShowCreateModal(false)} + onHide={handleCreateModalHide} /> )} {showDSLConfirmModal && ( setShowDSLConfirmModal(false)} + onCancel={handleCancelDSLConfirm} onConfirm={onConfirmDSL} confirmDisabled={isFetching} /> @@ -339,7 +554,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { diff --git a/web/app/components/explore/learn-dify/__tests__/index.spec.tsx b/web/app/components/explore/learn-dify/__tests__/index.spec.tsx new file mode 100644 index 00000000000..d26cac9b4b5 --- /dev/null +++ b/web/app/components/explore/learn-dify/__tests__/index.spec.tsx @@ -0,0 +1,106 @@ +import type { App } from '@/models/explore' +import { render, screen } from '@testing-library/react' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +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: { + 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 } = createConsoleQueryWrapper({ + systemFeatures: { + enable_learn_app: enableLearnApp, + }, + }) + + return render( + , + { 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() + }) +}) diff --git a/web/app/components/explore/learn-dify/index.tsx b/web/app/components/explore/learn-dify/index.tsx index 6aa41641c96..5ab9e98fb01 100644 --- a/web/app/components/explore/learn-dify/index.tsx +++ b/web/app/components/explore/learn-dify/index.tsx @@ -16,11 +16,13 @@ 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 } @@ -37,6 +39,7 @@ const LearnDifyContent = ({ onCreate, onTry, showDescription = true, + stepByStepTourTarget, title, }: LearnDifyContentProps) => { const { t } = useTranslation() @@ -95,6 +98,7 @@ const LearnDifyContent = ({ isClosing ? { transform: collapseTransform, transformOrigin: 'center center' } : undefined } aria-labelledby="learn-dify-title" + data-step-by-step-tour-target={stepByStepTourTarget} >
@@ -153,7 +157,7 @@ const LearnDify = (props: LearnDifyProps) => { if (!systemFeatures.enable_learn_app) return null - if (props.dismissible === false) return + if (props.dismissible === false || props.forceVisible) return return } diff --git a/web/app/components/explore/try-app/app-info/index.tsx b/web/app/components/explore/try-app/app-info/index.tsx index a7105a7c6d4..8e92f0cb433 100644 --- a/web/app/components/explore/try-app/app-info/index.tsx +++ b/web/app/components/explore/try-app/app-info/index.tsx @@ -12,8 +12,10 @@ import useGetRequirements from './use-get-requirements' type Props = Readonly<{ appId: string appDetail: TryAppInfo + canCreate?: boolean categories?: string[] className?: string + createButtonStepByStepTourTarget?: string onCreate: () => void }> @@ -49,7 +51,15 @@ const RequirementIcon: FC = ({ iconUrl }) => { ) } -const AppInfo: FC = ({ appId, className, categories, appDetail, onCreate }) => { +const AppInfo: FC = ({ + appId, + canCreate = true, + className, + categories, + createButtonStepByStepTourTarget, + appDetail, + onCreate, +}) => { const { t } = useTranslation() const mode = appDetail?.mode const visibleCategories = Array.from(new Set(categories?.filter(Boolean) ?? [])) @@ -112,12 +122,19 @@ const AppInfo: FC = ({ appId, className, categories, appDetail, onCreate {appDetail.description}
)} - + {canCreate && ( + + )} {visibleCategories.length > 0 && (
diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx index 29ee7f8f2ca..d0c419db965 100644 --- a/web/app/components/explore/try-app/index.tsx +++ b/web/app/components/explore/try-app/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable style/multiline-ternary */ 'use client' import type { FC } from 'react' import type { App as AppType } from '@/models/explore' @@ -21,12 +22,22 @@ import { TypeEnum } from './types' type Props = Readonly<{ appId: string app?: AppType + canCreate?: boolean categories?: string[] + createButtonStepByStepTourTarget?: string onClose: () => void onCreate: () => void }> -const TryApp: FC = ({ appId, app, categories, onClose, onCreate }) => { +const TryApp: FC = ({ + appId, + app, + canCreate = true, + categories, + createButtonStepByStepTourTarget, + onClose, + onCreate, +}) => { const { t } = useTranslation() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app) @@ -111,7 +122,9 @@ const TryApp: FC = ({ appId, app, categories, onClose, onCreate }) => { className="w-[360px] shrink-0" appDetail={appDetail} appId={appId} + canCreate={canCreate} categories={categories} + createButtonStepByStepTourTarget={createButtonStepByStepTourTarget} onCreate={onCreate} />
diff --git a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx index 704e5bd4f97..6a9138a2b94 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/empty.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/empty.tsx @@ -1,4 +1,5 @@ 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() { @@ -6,7 +7,10 @@ export function Empty() { const docLink = useDocLink() return ( -
+
( - ( +
+ data-step-by-step-tour-target={ + index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty : undefined + } + > + +
))} {dialogState?.mode === 'create' && ( {isDataSourceListLoading && } {!isDataSourceListLoading && !dataSources.length && ( -
+
@@ -141,18 +145,20 @@ const DataSourcePage = ({ layout, onOpenMarketplace, stickyToolbar }: DataSource )} {!isDataSourceListLoading && !!filteredDataSources.length && (
- {filteredDataSources.map((item) => { + {filteredDataSources.map((item, index) => { const pluginDetail = dataSourcePluginDetails.find( (plugin) => plugin.plugin_id === item.plugin_id, ) return ( - + data-step-by-step-tour-target={ + index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceFirstCard : undefined + } + > + +
) })}
diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx index 170ff4191d6..6a20a6a08b1 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx @@ -3,6 +3,10 @@ import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/t import { act, fireEvent, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' 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 { renderWithConsoleQuery } from '@/test/console/query-data' import { CurrentSystemQuotaTypeEnum, @@ -559,6 +563,18 @@ 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', @@ -728,6 +744,11 @@ 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', () => { diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx index b7b9c8928db..c11eb29a0e0 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx @@ -2,6 +2,10 @@ import type { Mock } from 'vitest' 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' @@ -119,6 +123,38 @@ 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( + , + ) + + const selector = getStepByStepTourTargetSelector( + STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall, + ) + const target = document.querySelector(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: [ diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx index 6e5fef85369..6e5139af1b2 100644 --- a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx @@ -18,11 +18,13 @@ type InstallFromMarketplaceProps = { onOpenMarketplace?: () => void providers: ModelProvider[] searchText: string + stepByStepTourTarget?: string } const InstallFromMarketplace = ({ onOpenMarketplace, providers, searchText, + stepByStepTourTarget, }: InstallFromMarketplaceProps) => { const { t } = useTranslation() const { theme } = useTheme() @@ -42,54 +44,61 @@ const InstallFromMarketplace = ({ return (
-
- -
- - {t(($) => $['modelProvider.discoverMore'], { ns: 'common' })} - - {onOpenMarketplace ? ( - - ) : ( - - {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} -
-
- {!collapse && isAllPluginsLoading && } - {!isAllPluginsLoading && !collapse && ( - +
- )} +
+ +
+ + {t(($) => $['modelProvider.discoverMore'], { ns: 'common' })} + + {onOpenMarketplace ? ( + + ) : ( + + {t(($) => $['marketplace.difyMarketplace'], { ns: 'plugin' })} +
+
+ {!collapse && isAllPluginsLoading && } + {!isAllPluginsLoading && !collapse && ( + + )} +
) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx b/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx index 14992363bf5..97748322f2e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-provider-page-body.tsx @@ -4,6 +4,7 @@ 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' @@ -58,11 +59,20 @@ function ModelProviderListSkeleton() { ) } -function EmptyProviderState({ enableMarketplace }: { enableMarketplace: boolean }) { +function EmptyProviderState({ + enableMarketplace, + stepByStepTourTarget, +}: { + enableMarketplace: boolean + stepByStepTourTarget?: string +}) { const { t } = useTranslation() return ( -
+
@@ -80,7 +90,9 @@ function EmptyProviderState({ enableMarketplace }: { enableMarketplace: boolean 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' })} + ), }} /> @@ -93,6 +105,7 @@ function EmptyProviderState({ enableMarketplace }: { enableMarketplace: boolean } type ProviderCardListProps = { + firstCardTarget?: string providers: ModelProvider[] pluginDetailMap: Map notConfigured?: boolean @@ -104,7 +117,12 @@ function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map { const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap) const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap) @@ -116,16 +134,20 @@ function ProviderCardList({ providers, pluginDetailMap, notConfigured }: Provide return (
- {sortedProviders.map((provider) => { + {sortedProviders.map((provider, index) => { const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider)) return ( - + data-step-by-step-tour-target={index === 0 ? firstCardTarget : undefined} + > + +
) })}
@@ -151,7 +173,9 @@ const ModelProviderPageBody: FC = ({ return (
{IS_CLOUD_EDITION && ( -
+
)} @@ -160,9 +184,19 @@ const ModelProviderPageBody: FC = ({
)} - {showEmptyProvider && } + {showEmptyProvider && ( + + )} {showConfiguredProviders && ( @@ -173,6 +207,11 @@ const ModelProviderPageBody: FC = ({ {t(($) => $['modelProvider.toBeConfigured'], { ns: 'common' })}
= ({ providers={providers} searchText={searchText} onOpenMarketplace={onOpenMarketplace} + stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall} />
)} diff --git a/web/app/components/integrations/__tests__/page.spec.tsx b/web/app/components/integrations/__tests__/page.spec.tsx index 128056f7e90..129b3263b2d 100644 --- a/web/app/components/integrations/__tests__/page.spec.tsx +++ b/web/app/components/integrations/__tests__/page.spec.tsx @@ -1,5 +1,6 @@ -import { screen, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { renderWithNuqs } from '@/test/nuqs-testing' import IntegrationsPage from '../page' @@ -16,12 +17,14 @@ const { mockCanManagement, mockCanDebugger, mockCanSetPermissions, + mockIsPermissionLoading, mockReferenceSetting, mockSetReferenceSettings, } = vi.hoisted(() => ({ mockCanManagement: vi.fn(() => true), mockCanDebugger: vi.fn(() => true), mockCanSetPermissions: vi.fn(() => true), + mockIsPermissionLoading: vi.fn(() => false), mockReferenceSetting: vi.fn(() => ({ permission: { install_permission: 'everyone', @@ -54,6 +57,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ canSetPermissions: mockCanSetPermissions(), canSetPluginPreferences: mockCanSetPermissions(), canUpdatePlugin: true, + isPermissionLoading: mockIsPermissionLoading(), setPluginPermissionSettings: mockSetReferenceSettings, }), default: () => ({ @@ -65,6 +69,7 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({ canSetPermissions: mockCanSetPermissions(), canSetPluginPreferences: mockCanSetPermissions(), canUpdatePlugin: true, + isPermissionLoading: mockIsPermissionLoading(), setReferenceSettings: mockSetReferenceSettings, }), })) @@ -317,6 +322,7 @@ describe('IntegrationsPage', () => { mockCanManagement.mockReturnValue(true) mockCanDebugger.mockReturnValue(true) mockCanSetPermissions.mockReturnValue(true) + mockIsPermissionLoading.mockReturnValue(false) mockConsoleState.workspacePermissionKeys = ['tool.manage', 'mcp.manage'] mockReferenceSetting.mockReturnValue({ permission: { @@ -399,6 +405,43 @@ describe('IntegrationsPage', () => { ) }) + it('anchors step-by-step tour targets inside stable sidebar rows', () => { + renderIntegrationsPage({ section: 'mcp' }) + + const targetRows = [ + { + label: 'common.settings.provider', + target: STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, + }, + { + label: 'common.toolsPage.toolPlugin', + target: STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, + }, + { + label: 'MCP', + target: STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, + }, + { + label: 'common.settings.dataSource', + target: STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, + }, + { + label: 'plugin.categorySingle.trigger', + target: STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, + }, + ] + + targetRows.forEach(({ label, target }) => { + const row = screen.getByRole('link', { name: label }) + const targetAnchor = row.querySelector(`[data-step-by-step-tour-target="${target}"]`) + + expect(row).not.toHaveAttribute('data-step-by-step-tour-target') + expect(row).toHaveClass('relative') + expect(targetAnchor).toBeInTheDocument() + expect(targetAnchor).toHaveClass('absolute', 'inset-y-1', 'left-0', 'right-0') + }) + }) + it('keeps sidebar item icons outlined when the item is active', () => { const providerView = renderIntegrationsPage({ section: 'provider' }) @@ -731,6 +774,16 @@ describe('IntegrationsPage', () => { screen.queryByRole('link', { name: 'common.toolsPage.toolPlugin' }), ).not.toBeInTheDocument() expect(screen.queryByRole('link', { name: 'MCP' })).not.toBeInTheDocument() + + view.rerender() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'common.menus.tools' })).toHaveAttribute( + 'aria-expanded', + 'true', + ) + }) + expect(screen.getByRole('link', { name: 'MCP' })).toHaveClass('bg-state-base-active') }) it('renders the tools header for tool sections', () => { diff --git a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx index 128b1dd9a3d..936f889b071 100644 --- a/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx +++ b/web/app/components/integrations/__tests__/tool-provider-list.spec.tsx @@ -1,6 +1,10 @@ import type { ComponentProps, ReactNode } from 'react' import { cleanup, fireEvent, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getStepByStepTourTargetSelector, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' import { getToolType } from '@/app/components/tools/utils' import { createConsoleQueryWrapper } from '@/test/console/query-data' import { renderWithNuqs } from '@/test/nuqs-testing' @@ -213,7 +217,11 @@ vi.mock('@/app/components/tools/labels/filter', () => ({ })) vi.mock('@/app/components/tools/provider/custom-create-card', () => ({ - default: () =>
Create Custom Tool
, + default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => ( +
+ Create Custom Tool +
+ ), NewCustomToolButton: () => ( ) @@ -106,6 +123,7 @@ export function IntegrationSidebarNavItem({ aria-current={isActive ? 'page' : undefined} className={className} > + {content} ) diff --git a/web/app/components/integrations/tool-provider-create-action.tsx b/web/app/components/integrations/tool-provider-create-action.tsx index c1b9554cf64..37fc59e2f7b 100644 --- a/web/app/components/integrations/tool-provider-create-action.tsx +++ b/web/app/components/integrations/tool-provider-create-action.tsx @@ -20,7 +20,7 @@ const ToolProviderCreateAction = ({ onCustomToolCreated, onMCPProviderCreated, }: ToolProviderCreateActionProps) => { - if (activeTab === 'mcp' && hasCategoryCollections) + if (activeTab === 'mcp') return ( onMCPProviderCreated(provider.id)} diff --git a/web/app/components/integrations/tool-provider-list.tsx b/web/app/components/integrations/tool-provider-list.tsx index e101cfef252..a3c58a67cca 100644 --- a/web/app/components/integrations/tool-provider-list.tsx +++ b/web/app/components/integrations/tool-provider-list.tsx @@ -131,7 +131,7 @@ const ProviderList = ({ category, contentInset = 'default', layout }: ProviderLi canManageTools && !(activeTab === 'api' && !isCollectionListLoading && hasCategoryCollections) const shouldShowMCPCreateCard = canManageMCP && !(activeTab === 'mcp' && hasCategoryCollections) const shouldShowToolbarCreateAction = - (activeTab === 'mcp' && canManageMCP && hasCategoryCollections) || + (activeTab === 'mcp' && canManageMCP && (hasCategoryCollections || isRouteCategory)) || (activeTab === 'api' && canManageTools && !isCollectionListLoading && hasCategoryCollections) const filteredCollectionList = useMemo(() => { return activeTabCollectionList.filter((collection) => { diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index db516d11bd5..f9b6d6e7a56 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -1,5 +1,10 @@ +import type { + StepByStepTourStatePatchPayload, + StepByStepTourStateResponse, +} from '@dify/contracts/api/console/onboarding/types.gen' import type { ReactNode } from 'react' import type { Mock } from 'vitest' +import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types' import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import type { ICurrentWorkspace, IWorkspace } from '@/models/common' @@ -7,11 +12,16 @@ import type { InstalledApp } from '@/models/explore' import type { ConsoleStateFixture } from '@/test/console/state-fixture' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createStore, Provider as JotaiProvider } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' import { Plan } from '@/app/components/billing/type' import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage' import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage' import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state' +import { STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY } from '@/app/components/step-by-step-tour/storage' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { userProfileQueryOptions } from '@/features/account-profile/client' @@ -19,17 +29,119 @@ import { usePathname, useRouter } from '@/next/navigation' import { consoleQuery } from '@/service/client' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data' +import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' import { AppModeEnum } from '@/types/app' import { MainNav } from '../index' +type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean } + const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface' const activeStackingClassName = 'aria-[current=page]:z-1' +const mockTrackEvent = vi.hoisted(() => vi.fn()) const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoisted(() => ({ mockSwitchWorkspace: vi.fn(), mockToastSuccess: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), })) +const mockStepByStepTour = vi.hoisted(() => { + const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const + const createState = ( + overrides: Partial = {}, + ): StepByStepTourStateResponse => ({ + first_workspace_id: 'workspace-1', + skipped: false, + completed_task_ids: [], + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: [], + updated_at: '2026-07-01T00:00:00Z', + ...overrides, + }) + const createUiState = ( + overrides: Partial = {}, + ): StepByStepTourTestUiState => ({ + activeGuideGroup: undefined, + activeGuideIndex: undefined, + activeGuideIndexes: undefined, + activeTaskId: undefined, + minimized: false, + ...overrides, + }) + let state = createState() + let uiState = createUiState() + const patchState = vi.fn( + async ({ + body, + }: { + body: StepByStepTourStatePatchPayload + }): Promise => { + switch (body.action) { + 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 + case 'skip': + state = { + ...state, + skipped: true, + manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter( + (id) => id !== 'workspace-1', + ), + } + break + } + + return state + }, + ) + + return { + get state() { + return state + }, + get uiState() { + return uiState + }, + patchState, + reset() { + state = createState() + uiState = createUiState() + patchState.mockClear() + }, + setState(overrides: Partial = {}) { + state = createState(overrides) + }, + setUiState(overrides: Partial = {}) { + uiState = createUiState(overrides) + if (overrides.minimized !== undefined) { + localStorage.setItem( + STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, + overrides.minimized ? 'collapsed' : 'expanded', + ) + } + }, + stateQueryKey, + } +}) const mockConsoleState = vi.hoisted(() => ({ current: undefined as ConsoleStateFixture | undefined, })) @@ -38,6 +150,10 @@ vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), })) +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + vi.mock('@/context/account-state', async () => { const { createAccountStateModuleMock } = await import('@/test/console/state-fixture') return createAccountStateModuleMock(() => mockConsoleState.current ?? {}) @@ -61,6 +177,10 @@ vi.mock('@/context/provider-context', () => ({ vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), + useModalContextSelector: (selector: (state: { hasBlockingModalOpen: boolean }) => T) => + selector({ + hasBlockingModalOpen: false, + }), })) vi.mock('@/next/navigation', async (importOriginal) => { @@ -72,6 +192,39 @@ vi.mock('@/next/navigation', async (importOriginal) => { } }) +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return { + ...actual, + ...createReactI18nextMock({ + 'common.stepByStepTour.title': 'Get to know Dify', + 'common.stepByStepTour.duration': 'A quick tour — about 5 minutes', + 'common.stepByStepTour.skip': 'Skip tour', + 'common.stepByStepTour.minimize': 'Minimize tour', + 'common.stepByStepTour.restore': 'Open step-by-step tour', + 'common.stepByStepTour.learnMore': 'Learn more', + 'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson', + 'common.stepByStepTour.tasks.home.description': + 'Open a hands-on lesson from Learn Dify to see Dify in action.', + 'common.stepByStepTour.tasks.home.primaryActionLabel': 'Show me', + 'common.stepByStepTour.tasks.studio.title': 'Manage your apps in Studio', + 'common.stepByStepTour.tasks.studio.description': + 'All your apps live in Studio — edit, organize, and publish them here.', + 'common.stepByStepTour.tasks.studio.primaryActionLabel': 'Take a look', + 'common.stepByStepTour.tasks.knowledge.title': 'Add your own data', + 'common.stepByStepTour.tasks.knowledge.description': + 'Build a knowledge base so your apps answer from your documents.', + 'common.stepByStepTour.tasks.knowledge.primaryActionLabel': 'Take a look', + 'common.stepByStepTour.tasks.integration.title': 'Explore integrations', + 'common.stepByStepTour.tasks.integration.description': + 'Models, tools, data sources & more — explore what you can connect.', + 'common.stepByStepTour.tasks.integration.primaryActionLabel': 'Take a look', + }), + } +}) + vi.mock('@/service/client', async (importOriginal) => { const actual = await importOriginal() const currentWorkspaceQueryKey = ['console', 'workspaces', 'current', 'post'] as const @@ -107,6 +260,27 @@ vi.mock('@/service/client', async (importOriginal) => { }, } } + if (prop === 'onboarding') { + return { + stepByStepTour: { + state: { + get: { + queryKey: () => mockStepByStepTour.stateQueryKey, + queryOptions: () => ({ + queryKey: mockStepByStepTour.stateQueryKey, + queryFn: async () => mockStepByStepTour.state, + }), + }, + patch: { + mutationOptions: (options = {}) => ({ + mutationFn: mockStepByStepTour.patchState, + ...options, + }), + }, + }, + }, + } + } return Reflect.get(target, prop, receiver) }, @@ -249,11 +423,12 @@ type MainNavSystemFeatures = Exclude< const defaultMainNavSystemFeatures: MainNavSystemFeatures = { branding: { enabled: false }, enable_marketplace: true, + enable_step_by_step_tour: true, } const renderMainNav = ( systemFeatures: MainNavSystemFeatures = defaultMainNavSystemFeatures, - options: { extra?: ReactNode } = {}, + options: { store?: ReturnType; extra?: ReactNode } = {}, ) => { const queryClient = createConsoleQueryClient() const currentConsoleState = mockConsoleState.current ?? consoleState @@ -273,6 +448,11 @@ const renderMainNav = ( }, }) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) + queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state) + const store = options.store ?? createStore() + seedRegisteredConsoleStateFixture(store) + store.set(queryClientAtom, queryClient) + store.set(stepByStepTourSessionAtom, mockStepByStepTour.uiState) const resolvedSystemFeatures = { ...defaultMainNavSystemFeatures, ...systemFeatures, @@ -282,10 +462,10 @@ const renderMainNav = ( }, } return renderWithConsoleQuery( - <> + {options.extra} - , + , { systemFeatures: resolvedSystemFeatures, queryClient }, ) } @@ -316,6 +496,7 @@ describe('MainNav', () => { current: false, }, ] + mockStepByStepTour.reset() mockIsAgentV2Enabled.mockReturnValue(true) ;(usePathname as Mock).mockImplementation(() => mockPathname) @@ -591,6 +772,19 @@ describe('MainNav', () => { expect(screen.queryByRole('link', { name: /Agents/ })).not.toBeInTheDocument() }) + it('keeps MainNav on primary navigation when it is mounted on a detail route', () => { + mockPathname = '/app/app-1/overview' + + renderMainNav() + + expect(screen.queryByTestId('app-detail-top')).not.toBeInTheDocument() + expect(screen.queryByTestId('app-detail-section')).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }), + ).toBeInTheDocument() + expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps') + }) + it.each(['/deployments', '/deployments/create'])( 'keeps global navigation on deployment collection route %s', (pathname) => { @@ -713,6 +907,94 @@ describe('MainNav', () => { expect(mockPush).not.toHaveBeenCalled() }) + it('shows Step-by-step Tour switch in help menu and stores the current workspace disable override', async () => { + const user = userEvent.setup() + renderMainNav({ enable_learn_app: true }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { + name: 'common.mainNav.help.stepByStepTour', + }) + expect(stepByStepTourItem).toHaveAttribute('aria-checked', 'true') + + await user.click(stepByStepTourItem) + + await waitFor(() => { + expect(mockStepByStepTour.patchState.mock.calls[0]?.[0]).toEqual({ + body: { action: 'disable_current_workspace' }, + }) + }) + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + expect(screen.getByRole('menu')).toBeInTheDocument() + expect(mockPush).not.toHaveBeenCalled() + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_disabled' }) + }) + + it('shows Step-by-step Tour switch off for existing accounts without a default workspace', async () => { + const user = userEvent.setup() + mockStepByStepTour.setState({ + first_workspace_id: null, + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: [], + }) + + renderMainNav({ enable_learn_app: true }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + expect( + await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), + ).toHaveAttribute('aria-checked', 'false') + }) + + it('lets existing accounts enable Step-by-step Tour from the help menu', async () => { + const user = userEvent.setup() + localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') + mockStepByStepTour.setState({ + first_workspace_id: null, + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: [], + }) + + renderMainNav({ enable_learn_app: true }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + const stepByStepTourItem = await screen.findByRole('menuitemcheckbox', { + name: 'common.mainNav.help.stepByStepTour', + }) + expect(stepByStepTourItem).toHaveAttribute('aria-checked', 'false') + + await user.click(stepByStepTourItem) + + await waitFor(() => { + expect(mockStepByStepTour.patchState.mock.lastCall?.[0]).toEqual({ + body: { action: 'enable_current_workspace' }, + }) + expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded') + }) + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_enabled' }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + expect( + await screen.findByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), + ).toHaveAttribute('aria-checked', 'true') + }) + + it('hides Step-by-step Tour switch when the feature is disabled', async () => { + const user = userEvent.setup() + renderMainNav({ + enable_learn_app: true, + enable_step_by_step_tour: false, + }) + + await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })) + + await screen.findByText('common.mainNav.help.docs') + expect( + screen.queryByRole('menuitemcheckbox', { name: 'common.mainNav.help.stepByStepTour' }), + ).not.toBeInTheDocument() + }) + it('hides Learn Dify switch in help menu when learn app is disabled', async () => { renderMainNav({ enable_learn_app: false }) @@ -733,6 +1015,7 @@ describe('MainNav', () => { 'common.mainNav.help.docs', 'common.userProfile.roadmap', 'common.mainNav.help.learnDify', + 'common.mainNav.help.stepByStepTour', 'common.userProfile.compliance', 'common.userProfile.forum', 'common.userProfile.community', diff --git a/web/app/components/main-nav/components/help-menu.module.css b/web/app/components/main-nav/components/help-menu.module.css new file mode 100644 index 00000000000..6a1e1e36770 --- /dev/null +++ b/web/app/components/main-nav/components/help-menu.module.css @@ -0,0 +1,37 @@ +@reference "../../../styles/globals.css"; + +.stepByStepTourRecoveryPulse { + animation: step-by-step-tour-help-pulse 2.4s ease-out 1; +} + +@media (prefers-reduced-motion: reduce) { + .stepByStepTourRecoveryPulse { + animation: none; + } +} + +@keyframes step-by-step-tour-help-pulse { + 0% { + background-color: var(--color-components-card-bg); + box-shadow: 0 0 0 0 var(--color-state-accent-hover-alt); + color: var(--color-text-tertiary); + } + + 18% { + background-color: var(--color-state-accent-hover); + box-shadow: 0 0 0 6px var(--color-state-accent-active-alt); + color: var(--color-saas-dify-blue-inverted); + } + + 56% { + background-color: var(--color-state-accent-hover); + box-shadow: 0 0 0 12px transparent; + color: var(--color-saas-dify-blue-inverted); + } + + 100% { + background-color: var(--color-components-card-bg); + box-shadow: 0 0 0 0 transparent; + color: var(--color-text-tertiary); + } +} diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index 72da4193832..227a11a3c1d 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -12,8 +12,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { Switch } from '@langgenius/dify-ui/switch' import { useSuspenseQuery } from '@tanstack/react-query' -import { useAtomValue } from 'jotai' +import { useAtomValue, useSetAtom } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { @@ -27,12 +28,26 @@ import { MenuItemContent, } from '@/app/components/header/account-dropdown/menu-item-content' import GithubStar from '@/app/components/header/github-star' +import { trackStepByStepTourEvent } from '@/app/components/step-by-step-tour/analytics' +import { + disableStepByStepTourForCurrentWorkspaceAtom, + enableStepByStepTourForCurrentWorkspaceAtom, + stepByStepTourEnabledForCurrentWorkspaceAtom, + stepByStepTourSkipRecoveryVisibleAtom, + stepByStepTourStateUpdatingAtom, +} from '@/app/components/step-by-step-tour/state' +import { useSetStepByStepTourShellMode } from '@/app/components/step-by-step-tour/storage' import { IS_CLOUD_EDITION } from '@/config' import { useDocLink } from '@/context/i18n' import { langGeniusVersionInfoAtom } from '@/context/version-state' -import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state' +import { + currentWorkspaceIdAtom, + currentWorkspaceLoadingAtom, + isCurrentWorkspaceOwnerAtom, +} from '@/context/workspace-state' import { env } from '@/env' import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import styles from './help-menu.module.css' import SupportMenu from './support-menu' type HelpMenuProps = { @@ -58,23 +73,72 @@ const defaultTriggerIcon = ( ) +const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => ( +
)}
-
-
- -
-
- +
+
+
+ +
+
+ +
+
) diff --git a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx index ff87c4ae5a6..cb9f5945ea0 100644 --- a/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/plugins-panel.spec.tsx @@ -2,6 +2,10 @@ import type { PluginDetail } from '../../types' import type { Collection } from '@/app/components/tools/types' import { fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getStepByStepTourTargetSelector, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' import { PluginCategoryEnum } from '../../types' import PluginsPanel from '../plugins-panel' @@ -117,14 +121,20 @@ vi.mock('../empty', () => ({ vi.mock('../list', () => ({ default: ({ children, + firstPluginTarget, pluginList, }: { children?: React.ReactNode + firstPluginTarget?: string pluginList: PluginDetail[] }) => (
- {pluginList.map((plugin) => ( -
+ {pluginList.map((plugin, index) => ( +
{plugin.plugin_id}
))} @@ -624,6 +634,39 @@ describe('PluginsPanel', () => { expect(screen.getByTestId('plugin-list')).toHaveTextContent('rag-extension') }) + it.each([ + [ + PluginCategoryEnum.trigger, + 'trigger-plugin', + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerGrid, + ], + [ + PluginCategoryEnum.agent, + 'agent-plugin', + STEP_BY_STEP_TOUR_TARGETS.integrationAgentStrategyEmpty, + ], + [ + PluginCategoryEnum.extension, + 'extension-plugin', + STEP_BY_STEP_TOUR_TARGETS.integrationExtensionGrid, + ], + ] as const)( + 'anchors the %s integration tour target to the first plugin result', + (category, pluginId, targetName) => { + mockPluginListWithLatestVersion.mockReturnValue([ + createPlugin(pluginId, pluginId, [], category), + createPlugin(`${pluginId}-2`, `${pluginId} 2`, [], category), + ]) + + render() + + const selector = getStepByStepTourTargetSelector(targetName) + + expect(document.querySelector(selector)).toBe(screen.getAllByTestId('plugin-list-item')[0]) + expect(document.querySelector(selector)).not.toBe(screen.getByTestId('plugin-list')) + }, + ) + it('leaves the result area blank when a fixed integrations category search has no matches', () => { mockState.filters.searchQuery = 'missing' mockPluginListWithLatestVersion.mockReturnValue([ diff --git a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx index 5304c1381c5..c3f47d49221 100644 --- a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx @@ -3,6 +3,10 @@ import type { ReactElement } from 'react' import type { FilterState } from '../../filter-management' import { act, fireEvent, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + getStepByStepTourTargetSelector, + STEP_BY_STEP_TOUR_TARGETS, +} from '@/app/components/step-by-step-tour/target-registry' import { InstallationScope } from '@/features/system-features/constants' import { renderWithConsoleQuery } from '@/test/console/query-data' // ==================== Imports (after mocks) ==================== @@ -176,6 +180,23 @@ describe('Empty Component', () => { buttons.forEach((button) => expect(button).toHaveClass('h-8', 'w-full', 'justify-start')) }) + it('should anchor the trigger tour target to the empty state content instead of the grow root', async () => { + const { container } = render() + await flushEffects() + + const selector = getStepByStepTourTargetSelector( + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerGrid, + ) + const target = document.querySelector(selector) + + expect(container.firstElementChild).not.toHaveAttribute('data-step-by-step-tour-target') + expect(target).toContainElement(screen.getByText('plugin.list.noTriggerFound')) + expect(target).toContainElement(screen.getByText('plugin.source.marketplace')) + expect(target).not.toContainElement( + screen.getByText('plugin.installModal.dropIntegrationToInstall'), + ) + }) + it('should render the Figma agent strategy empty layout at the shared center position', async () => { // Arrange & Act const { container } = render( @@ -196,6 +217,25 @@ describe('Empty Component', () => { ).toHaveClass('size-6', 'shrink-0') }) + it('should anchor the agent strategy tour target to the empty state content instead of the grow root', async () => { + const { container } = render( + , + ) + await flushEffects() + + const selector = getStepByStepTourTargetSelector( + STEP_BY_STEP_TOUR_TARGETS.integrationAgentStrategyEmpty, + ) + const target = document.querySelector(selector) + + expect(container.firstElementChild).not.toHaveAttribute('data-step-by-step-tour-target') + expect(target).toContainElement(screen.getByText('plugin.list.noAgentStrategyFound')) + expect(target).toContainElement(screen.getByText('plugin.source.marketplace')) + expect(target).not.toContainElement( + screen.getByText('plugin.installModal.dropIntegrationToInstall'), + ) + }) + it('should render the Figma extension empty layout with extension copy', async () => { // Arrange & Act const { container } = render() @@ -211,6 +251,23 @@ describe('Empty Component', () => { 'shrink-0', ) }) + + it('should anchor the extension tour target to the empty state content instead of the grow root', async () => { + const { container } = render() + await flushEffects() + + const selector = getStepByStepTourTargetSelector( + STEP_BY_STEP_TOUR_TARGETS.integrationExtensionGrid, + ) + const target = document.querySelector(selector) + + expect(container.firstElementChild).not.toHaveAttribute('data-step-by-step-tour-target') + expect(target).toContainElement(screen.getByText('plugin.list.noExtensionFound')) + expect(target).toContainElement(screen.getByText('plugin.source.marketplace')) + expect(target).not.toContainElement( + screen.getByText('plugin.installModal.dropIntegrationToInstall'), + ) + }) }) // ==================== Text Display Tests (useMemo) ==================== diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx index 1180a3f3a0a..1c5b0f6f647 100644 --- a/web/app/components/plugins/plugin-page/empty/index.tsx +++ b/web/app/components/plugins/plugin-page/empty/index.tsx @@ -14,6 +14,7 @@ import { Github } from '@/app/components/base/icons/src/vender/solid/general' import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github' import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package' +import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry' import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useInstalledPluginList } from '@/service/use-plugins' @@ -162,6 +163,13 @@ const Empty = ({ : isIntegrationsExtension ? t(($) => $['list.noExtensionFound'], { ns: 'plugin' }) : text + const emptyTarget = isIntegrationsTrigger + ? STEP_BY_STEP_TOUR_TARGETS.integrationTriggerGrid + : isIntegrationsAgentStrategy + ? STEP_BY_STEP_TOUR_TARGETS.integrationAgentStrategyEmpty + : isIntegrationsExtension + ? STEP_BY_STEP_TOUR_TARGETS.integrationExtensionGrid + : undefined const placeholderItemCount = isIntegrationsCategory ? 14 : 20 return ( @@ -205,6 +213,7 @@ const Empty = ({ 'flex flex-col items-center', isIntegrationsCategory ? 'gap-y-6' : 'gap-y-3', )} + data-step-by-step-tour-target={emptyTarget} >
= ({ canDeletePlugin = true, canUpdatePlugin = true, children, + firstPluginTarget, pluginList, }) => { return (
-
- {pluginList.map((plugin) => ( - + {pluginList.map((plugin, index) => ( +
+ data-step-by-step-tour-target={index === 0 ? firstPluginTarget : undefined} + > + +
))} {children}
diff --git a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx index bd50b2a7224..32f7c667b89 100644 --- a/web/app/components/plugins/plugin-page/plugins-panel-results.tsx +++ b/web/app/components/plugins/plugin-page/plugins-panel-results.tsx @@ -60,6 +60,8 @@ type PluginsPanelResultsProps = { contentFrameClassName: string contentInset: PluginPageContentInset currentBuiltinToolID?: string + firstBuiltinToolTarget?: string + firstPluginTarget?: string filteredBuiltinTools: Collection[] filteredList: Array hasToolMarketplacePanel: boolean @@ -82,6 +84,8 @@ const PluginsPanelResults = ({ contentFrameClassName, contentInset, currentBuiltinToolID, + firstBuiltinToolTarget, + firstPluginTarget, filteredBuiltinTools, filteredList, hasToolMarketplacePanel, @@ -119,13 +123,17 @@ const PluginsPanelResults = ({ pluginList={filteredList} canDeletePlugin={canDeletePlugin} canUpdatePlugin={canUpdatePlugin} + firstPluginTarget={firstPluginTarget} > - {filteredBuiltinTools.map((collection) => ( + {filteredBuiltinTools.map((collection, index) => ( + +
+ + ) +} + +describe('useStepByStepTourControlledDropdown', () => { + it('keeps ordinary dropdown toggle behavior when no tour controls it', () => { + render() + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' })) + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' })) + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false') + }) + + it('opens for a tour step without locking the dropdown open', () => { + render() + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true') + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'true') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' })) + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false') + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'false') + }) + + it('can keep a tour-opened dropdown locked until the tour leaves the step', () => { + render() + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true') + + fireEvent.click(screen.getByRole('button', { name: 'Toggle menu' })) + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true') + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-controlled', 'true') + }) + + it('closes when the tour leaves the dropdown step', async () => { + const { rerender } = render() + + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true') + + rerender() + + await waitFor(() => { + expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'false') + }) + }) +}) + +describe('getStepByStepTourDropdownMenuContentProps', () => { + it('blocks presentation menu interactions without letting clicks bubble through', () => { + const onAction = vi.fn() + const onBackgroundClick = vi.fn() + const { popupClassName, popupProps, positionerProps } = + getStepByStepTourDropdownMenuContentProps({ + highlightPart: 'tour-menu', + interactionMode: 'presentation', + }) + + render( +
+
+
+ +
+
+
, + ) + + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete', hidden: true })) + + expect(onAction).not.toHaveBeenCalled() + expect(onBackgroundClick).not.toHaveBeenCalled() + expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true') + expect(screen.getByTestId('positioner')).toHaveAttribute( + 'data-step-by-step-tour-highlight-part', + 'tour-menu', + ) + expect(popupClassName).toContain('pointer-events-none') + }) + + it('leaves interactive menus clickable without bubbling through', () => { + const onAction = vi.fn() + const onBackgroundClick = vi.fn() + const onPopupClick = vi.fn() + const { popupProps, positionerProps } = getStepByStepTourDropdownMenuContentProps({ + highlightPart: 'tour-menu', + interactionMode: 'interactive', + popupProps: { + onClick: onPopupClick, + }, + }) + + render( +
+
+
+ +
+
+
, + ) + + fireEvent.click(screen.getByRole('menuitem', { name: 'Create' })) + + expect(onAction).toHaveBeenCalledTimes(1) + expect(onPopupClick).toHaveBeenCalledTimes(1) + expect(onBackgroundClick).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx new file mode 100644 index 00000000000..2f3c79cccef --- /dev/null +++ b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx @@ -0,0 +1,1401 @@ +import type { + StepByStepTourStatePatchPayload, + StepByStepTourStateResponse, +} from '@dify/contracts/api/console/onboarding/types.gen' +import type { StepByStepTourSessionState, StepByStepTourTaskId } from '../types' +import type { ICurrentWorkspace } from '@/models/common' +import type { ConsoleStateFixture } from '@/test/console/state-fixture' +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { createStore, Provider as JotaiProvider } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { Plan } from '@/app/components/billing/type' +import { systemFeaturesQueryOptions } from '@/features/system-features/client' +import { defaultSystemFeatures } from '@/features/system-features/config' +import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture' +import { createTestQueryClient } from '@/test/query-client' +import StepByStepTourMount from '../mount' +import { stepByStepTourSessionAtom } from '../state' +import { STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY } from '../storage' +import { STEP_BY_STEP_TOUR_TARGETS } from '../target-registry' + +type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean } + +type StepByStepTourFixtureState = StepByStepTourSessionState & { + completedTaskIds: StepByStepTourTaskId[] + firstWorkspaceId?: string + manuallyDisabledWorkspaceIds: string[] + manuallyEnabledWorkspaceIds: string[] + minimized: boolean + skipped: boolean + updatedAt?: string | null +} + +type WorkspaceRole = ICurrentWorkspace['role'] + +const mockRouterPush = vi.fn() +const mockTrackEvent = vi.hoisted(() => vi.fn()) +let mockPathname = '/apps' +const mockWorkspacePermissionKeys = vi.hoisted(() => ({ + value: [ + 'app.create_and_management', + 'dataset.create_and_management', + 'dataset.external.connect', + 'plugin.model_config', + 'plugin.plugin_preferences', + 'tool.manage', + 'mcp.manage', + 'api_extension.manage', + ], +})) +const mockIsCurrentWorkspaceManager = vi.hoisted(() => ({ + value: true, +})) +const mockCurrentWorkspaceRole = vi.hoisted(() => ({ + value: 'owner' as WorkspaceRole, +})) +const mockEnableLearnApp = vi.hoisted(() => ({ + value: true, +})) +const mockEnableStepByStepTour = vi.hoisted(() => ({ + value: true, +})) +const mockHasBlockingModalOpen = vi.hoisted(() => ({ + value: false, +})) +const mockStepByStepTour = vi.hoisted(() => { + const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const + const createState = ( + overrides: Partial = {}, + ): StepByStepTourStateResponse => ({ + first_workspace_id: + overrides.first_workspace_id === undefined ? 'workspace-1' : overrides.first_workspace_id, + skipped: overrides.skipped ?? false, + completed_task_ids: overrides.completed_task_ids ?? [], + manually_enabled_workspace_ids: overrides.manually_enabled_workspace_ids ?? [], + manually_disabled_workspace_ids: overrides.manually_disabled_workspace_ids ?? [], + updated_at: overrides.updated_at ?? '2026-07-01T00:00:00Z', + }) + const createUiState = ( + overrides: Partial = {}, + ): StepByStepTourTestUiState => ({ + activeGuideGroup: undefined, + activeGuideIndex: undefined, + activeGuideIndexes: undefined, + activeTaskId: undefined, + minimized: false, + ...overrides, + }) + let state = createState() + let uiState = createUiState() + const patchState = vi.fn( + async ({ + body, + }: { + body: StepByStepTourStatePatchPayload + }): Promise => { + 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 state() { + return state + }, + get uiState() { + return uiState + }, + patchState, + reset() { + state = createState() + uiState = createUiState() + patchState.mockClear() + }, + setState(overrides: Partial = {}) { + state = createState(overrides) + }, + setTestState(nextState: Partial) { + state = createState({ + completed_task_ids: nextState.completedTaskIds, + first_workspace_id: nextState.firstWorkspaceId, + manually_disabled_workspace_ids: nextState.manuallyDisabledWorkspaceIds, + manually_enabled_workspace_ids: nextState.manuallyEnabledWorkspaceIds, + skipped: nextState.skipped, + updated_at: nextState.updatedAt, + }) + uiState = createUiState({ + activeGuideGroup: nextState.activeGuideGroup, + activeGuideIndex: nextState.activeGuideIndex, + activeGuideIndexes: nextState.activeGuideIndexes, + activeTaskId: nextState.activeTaskId, + minimized: nextState.minimized, + }) + }, + stateQueryKey, + } +}) + +const setViewportSize = ({ height, width }: { height: number; width: number }) => { + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: height, + }) + Object.defineProperty(window, 'innerWidth', { + configurable: true, + value: width, + }) +} + +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.dify.ai${path}`, +})) + +vi.mock('@/context/modal-context', () => ({ + useModalContextSelector: (selector: (state: { hasBlockingModalOpen: boolean }) => T) => + selector({ + hasBlockingModalOpen: mockHasBlockingModalOpen.value, + }), +})) + +vi.mock('@/next/navigation', () => ({ + usePathname: () => mockPathname, + useRouter: () => ({ push: mockRouterPush }), +})) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + systemFeatures: { + get: { + queryKey: () => ['console', 'system-features'], + }, + }, + onboarding: { + stepByStepTour: { + state: { + get: { + queryKey: () => mockStepByStepTour.stateQueryKey, + queryOptions: () => ({ + queryKey: mockStepByStepTour.stateQueryKey, + queryFn: async () => mockStepByStepTour.state, + }), + }, + patch: { + mutationOptions: (options = {}) => ({ + mutationFn: mockStepByStepTour.patchState, + ...options, + }), + }, + }, + }, + }, + }, +})) + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + const { createReactI18nextMock } = await import('@/test/i18n-mock') + + return { + ...actual, + ...createReactI18nextMock({ + 'common.stepByStepTour.title': 'Get to know Dify', + 'common.stepByStepTour.duration': 'A quick tour — about 5 minutes', + 'common.stepByStepTour.guides.integration.dataSource.description': + 'Connect external data sources so Knowledge bases can pull from them.', + 'common.stepByStepTour.guides.integration.dataSource.title': 'Data Source', + 'common.stepByStepTour.guides.integration.mcp.description': + 'Connect MCP servers when your apps need access to external tools and services through MCP.', + 'common.stepByStepTour.guides.integration.mcp.title': 'MCP', + 'common.stepByStepTour.guides.integration.modelProvider.description': + 'Manage or install model providers here, set up model credentials, and check your Message Credits.', + 'common.stepByStepTour.guides.integration.modelProvider.title': 'Model Provider', + 'common.stepByStepTour.guides.integration.limitedAccess.dataSource.description': + 'Connect data sources here so Knowledge can bring in content from Drive, Notion, GitHub, Firecrawl, and more. Setup may require admin access.', + 'common.stepByStepTour.guides.integration.limitedAccess.mcp.description': + 'View connected MCP servers that expose external tools and services to apps. Adding or editing servers requires the right workspace permission.', + 'common.stepByStepTour.guides.integration.limitedAccess.modelProvider.description': + 'View model providers here, check model credentials, and see Message Credits. Installing or changing providers requires admin permission.', + 'common.stepByStepTour.guides.integration.limitedAccess.toolPlugin.description': + 'Browse installed tools and marketplace plugins that apps can call during execution. Ask an admin if you need to install or configure one.', + 'common.stepByStepTour.guides.integration.limitedAccess.trigger.description': + 'View triggers that turn third-party events into app inputs. Creating or managing triggers requires permission from your Workspace Owner or Admin.', + 'common.stepByStepTour.guides.integration.toolPlugin.description': + 'Manage built-in tools and marketplace plugins that apps can call during execution.', + 'common.stepByStepTour.guides.integration.toolPlugin.title': 'Tool Plugin', + 'common.stepByStepTour.guides.integration.trigger.description': + 'Convert third-party events into inputs your apps can act on.', + 'common.stepByStepTour.guides.integration.trigger.title': 'Trigger', + 'common.stepByStepTour.guides.integration.updateSettings.description': + 'Configure how integrations update automatically, including the update mode, scheduled time, and which integrations are included.', + 'common.stepByStepTour.guides.integration.updateSettings.title': 'Update Settings', + 'common.stepByStepTour.guides.home.create.description': 'Click here to make it yours', + 'common.stepByStepTour.guides.home.noCreate.description': + 'You can review lessons and see how Dify works here. Creating an app from a lesson requires a workspace where you have create permission, or help from an admin.', + 'common.stepByStepTour.guides.home.noCreate.title': 'Browse Learn Dify', + 'common.stepByStepTour.guides.home.pick.description': 'Pick a lesson to see how it works.', + 'common.stepByStepTour.guides.knowledge.empty.connect.description': + 'Already have a knowledge base elsewhere? Connect it via API — no data migration needed.', + 'common.stepByStepTour.guides.knowledge.empty.connect.title': + 'Connect to an external knowledge base', + 'common.stepByStepTour.guides.knowledge.empty.create.description': + 'Fastest way to get going. Upload documents and Dify handles chunking, indexing, and embedding for you. You can switch to custom anytime.', + 'common.stepByStepTour.guides.knowledge.empty.create.title': + 'Create a ready-to-use knowledge base', + 'common.stepByStepTour.guides.knowledge.empty.pipeline.description': + 'Define your own chunking, cleanup, and indexing flow when you need finer control over how documents become searchable.', + 'common.stepByStepTour.guides.knowledge.empty.pipeline.title': + 'Build a custom knowledge base', + 'common.stepByStepTour.guides.knowledge.withDatasets.create.description': + 'Use Create to add a new knowledge base — start ready-to-use, build a custom one from your documents, or connect to an external knowledge base.', + 'common.stepByStepTour.guides.knowledge.withDatasets.create.title': + 'Create a new knowledge base', + 'common.stepByStepTour.guides.knowledge.withDatasets.manage.description': + 'Tap any knowledge base to open its document management page — update documents, retrieval settings, and access from there.', + 'common.stepByStepTour.guides.knowledge.withDatasets.manage.title': + 'Open and manage each knowledge base', + 'common.stepByStepTour.guides.primaryActionLabel': 'Got it', + 'common.stepByStepTour.guides.studio.empty.blank.description': + 'Start from an empty canvas when you already know what to build.', + 'common.stepByStepTour.guides.studio.empty.blank.title': 'Create from blank', + 'common.stepByStepTour.guides.studio.empty.dsl.description': + 'Got a Dify DSL file? Import it here to restore an app you have shared or backed up.', + 'common.stepByStepTour.guides.studio.empty.dsl.title': 'Import a DSL file', + 'common.stepByStepTour.guides.studio.empty.learnDify.description': + 'New to Dify? Walk through a guided lesson first.', + 'common.stepByStepTour.guides.studio.empty.learnDify.title': 'Or start with Learn Dify', + 'common.stepByStepTour.guides.studio.empty.template.description': + 'Browse Dify templates and pick one that matches what you want to build.', + 'common.stepByStepTour.guides.studio.empty.template.title': 'Create from a template', + 'common.stepByStepTour.guides.studio.noCreate.empty.description': + 'You can view apps in this workspace, but there are no apps here yet. To create or edit apps, switch workspaces or ask your Workspace Owner or Admin for access.', + 'common.stepByStepTour.guides.studio.noCreate.empty.title': 'No apps to view yet', + 'common.stepByStepTour.guides.studio.noCreate.withApps.description': + 'You can browse apps in this workspace, but creating or editing apps requires permission. Switch to a workspace where you have access, or contact your Workspace Owner or Admin.', + 'common.stepByStepTour.guides.studio.noCreate.withApps.title': 'Studio is view-only for you', + 'common.stepByStepTour.guides.studio.withApps.create.description': + 'Use Create to add a new app — pick from a template, start from blank, or import a DSL file.', + 'common.stepByStepTour.guides.studio.withApps.create.title': 'Create a new app', + 'common.stepByStepTour.guides.studio.withApps.manage.description': + 'Tap any app to open its orchestration page — edit prompts, models, and logic, or manage its settings from there.', + 'common.stepByStepTour.guides.studio.withApps.manage.title': 'Open and manage each app', + 'common.stepByStepTour.skip': 'Skip tour', + 'common.stepByStepTour.skipRecovery.dismiss': 'Got it', + 'common.stepByStepTour.skipRecovery.label': 'Step-by-step Tour recovery tip', + 'common.stepByStepTour.skipRecovery.message': + 'Tour hidden. Turn it back on anytime in Help → Step-by-step Tour.', + 'common.stepByStepTour.completion.description': + 'You’ve seen the essentials. Time to build something.', + 'common.stepByStepTour.completion.dismiss': 'Dismiss', + 'common.stepByStepTour.completion.label': 'Step-by-step Tour completed', + 'common.stepByStepTour.completion.title': 'You’re all set', + 'common.stepByStepTour.minimize': 'Minimize tour', + 'common.stepByStepTour.restore': 'Open step-by-step tour', + 'common.stepByStepTour.learnMore': 'Learn more', + 'common.stepByStepTour.markTaskComplete': 'Mark {{title}} complete', + 'common.stepByStepTour.markTaskIncomplete': 'Mark {{title}} incomplete', + 'common.stepByStepTour.progressAriaValueText': '{{completed}} of {{total}} steps completed', + 'common.stepByStepTour.stepLabel': '{{current}} of {{total}}', + 'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson', + 'common.stepByStepTour.tasks.home.description': + 'Open a hands-on lesson from Learn Dify to see Dify in action.', + 'common.stepByStepTour.tasks.home.noCreate.title': 'Browse Learn Dify', + 'common.stepByStepTour.tasks.home.noCreate.description': + 'You can review lessons and see how Dify works here. Creating an app from a lesson requires a workspace where you have create permission, or help from an admin.', + 'common.stepByStepTour.tasks.home.primaryActionLabel': 'Show me', + 'common.stepByStepTour.tasks.studio.title': 'Manage your apps in Studio', + 'common.stepByStepTour.tasks.studio.description': + 'All your apps live in Studio — edit, organize, and publish them here.', + 'common.stepByStepTour.tasks.studio.noCreate.title': 'Find your apps in Studio', + 'common.stepByStepTour.tasks.studio.noCreate.description': + 'You can browse apps in this workspace, but creating or editing apps requires permission. Switch to a workspace where you have access, or contact your Workspace Owner or Admin.', + 'common.stepByStepTour.tasks.studio.primaryActionLabel': 'Take a look', + 'common.stepByStepTour.tasks.knowledge.title': 'Add your own data', + 'common.stepByStepTour.tasks.knowledge.description': + 'Build a knowledge base so your apps answer from your documents.', + 'common.stepByStepTour.tasks.knowledge.noPermission.title': 'Knowledge needs permission', + 'common.stepByStepTour.tasks.knowledge.noPermission.description': + 'To create or manage knowledge bases, switch to a workspace where you have access or contact your admin.', + 'common.stepByStepTour.tasks.knowledge.noPermission.primaryActionLabel': 'Got it', + 'common.stepByStepTour.tasks.knowledge.primaryActionLabel': 'Take a look', + 'common.stepByStepTour.tasks.integration.title': 'Explore integrations', + 'common.stepByStepTour.tasks.integration.description': + 'Models, tools, data sources & more — explore what you can connect.', + 'common.stepByStepTour.tasks.integration.noPermission.title': 'Explore Integrations', + 'common.stepByStepTour.tasks.integration.noPermission.description': + 'Browse models, tools, and data sources, and see how they are managed.', + 'common.stepByStepTour.tasks.integration.primaryActionLabel': 'Take a look', + }), + } +}) + +function getMockAppContextState() { + return { + currentWorkspace: { + id: 'workspace-1', + name: 'Solar Studio', + plan: Plan.sandbox, + status: 'normal', + role: mockCurrentWorkspaceRole.value, + created_at: 0, + providers: [], + trial_credits: 0, + trial_credits_used: 0, + next_credit_reset_date: 0, + }, + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager.value, + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + } satisfies ConsoleStateFixture +} + +vi.mock('@/context/permission-state', async () => { + const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture') + return createPermissionStateModuleMock(getMockAppContextState) +}) + +vi.mock('@/context/workspace-state', async () => { + const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture') + return createWorkspaceStateModuleMock(getMockAppContextState) +}) + +vi.mock('@/app/components/base/amplitude', () => ({ + trackEvent: mockTrackEvent, +})) + +type TestRect = { + height: number + left: number + top: number + width: number +} + +const createTourElement = ( + dataAttributeName: 'stepByStepTourHighlightPart' | 'stepByStepTourTarget', + targetName: string, + rect: TestRect, +) => { + const target = document.createElement('div') + target.dataset[dataAttributeName] = targetName + target.getBoundingClientRect = () => ({ + bottom: rect.top + rect.height, + height: rect.height, + left: rect.left, + right: rect.left + rect.width, + top: rect.top, + width: rect.width, + x: rect.left, + y: rect.top, + toJSON: () => ({}), + }) + document.body.appendChild(target) + return target +} + +const createTourTarget = (targetName: string, top = 114, rect?: Partial) => + createTourElement('stepByStepTourTarget', targetName, { + height: 64, + left: 472, + top, + width: 1012, + ...rect, + }) + +const createTourHighlightPart = (targetName: string, rect: TestRect) => + createTourElement('stepByStepTourHighlightPart', targetName, rect) + +const createDeferred = () => { + let reject!: (reason?: unknown) => void + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise + resolve = resolvePromise + }) + + return { promise, reject, resolve } +} + +const setStepByStepTourTestState = (state: Partial) => { + mockStepByStepTour.setTestState(state) + if (state.minimized !== undefined) { + localStorage.setItem( + STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, + state.minimized ? 'collapsed' : 'expanded', + ) + } +} + +const renderStepByStepTourMount = () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state) + queryClient.setQueryData(systemFeaturesQueryOptions().queryKey, { + ...defaultSystemFeatures, + enable_learn_app: mockEnableLearnApp.value, + enable_step_by_step_tour: mockEnableStepByStepTour.value, + }) + const jotaiStore = createStore() + seedRegisteredConsoleStateFixture(jotaiStore) + jotaiStore.set(queryClientAtom, queryClient) + jotaiStore.set(stepByStepTourSessionAtom, mockStepByStepTour.uiState) + + return render( + + + + + , + ) +} + +const expectStepByStepTourPatch = async (body: StepByStepTourStatePatchPayload) => { + await waitFor(() => { + expect(mockStepByStepTour.patchState.mock.calls.at(-1)?.[0]).toEqual({ body }) + }) +} + +let user: ReturnType + +describe('StepByStepTourMount', () => { + beforeEach(() => { + user = userEvent.setup() + vi.clearAllMocks() + mockWorkspacePermissionKeys.value = [ + 'app.create_and_management', + 'dataset.create_and_management', + 'dataset.external.connect', + 'plugin.model_config', + 'plugin.plugin_preferences', + 'tool.manage', + 'mcp.manage', + 'api_extension.manage', + ] + mockIsCurrentWorkspaceManager.value = true + mockCurrentWorkspaceRole.value = 'owner' + mockEnableLearnApp.value = true + mockEnableStepByStepTour.value = true + mockHasBlockingModalOpen.value = false + mockPathname = '/apps' + localStorage.clear() + mockStepByStepTour.reset() + setViewportSize({ height: 768, width: 1024 }) + globalThis.ResizeObserver = class ResizeObserver { + constructor(_callback: ResizeObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + } as typeof ResizeObserver + }) + + it('does not render the checklist when the Step-by-step Tour feature is disabled', async () => { + mockEnableStepByStepTour.value = false + + renderStepByStepTourMount() + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + }) + + it('renders the checklist for an eligible workspace', async () => { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + }) + + it('keeps existing accounts hidden by default without a first workspace or manual enable', async () => { + mockStepByStepTour.setState({ + first_workspace_id: null, + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: [], + completed_task_ids: [], + skipped: false, + }) + + renderStepByStepTourMount() + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + }) + + it('hides the checklist and shows a dismissible recovery hint after Skip', async () => { + renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Skip tour' })) + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + expect( + screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).toBeInTheDocument() + expect( + screen.getByText('Tour hidden. Turn it back on anytime in Help → Step-by-step Tour.'), + ).toBeInTheDocument() + await expectStepByStepTourPatch({ action: 'skip' }) + + await user.click(screen.getByRole('button', { name: 'Got it' })) + + expect( + screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).not.toBeInTheDocument() + }) + + it('restores the checklist after Skip fails and allows retry', async () => { + const deferred = createDeferred() + mockStepByStepTour.patchState.mockImplementationOnce(() => deferred.promise) + renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Skip tour' })) + + await waitFor(() => { + expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(1) + expect( + screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).toBeInTheDocument() + }) + deferred.reject(new Error('patch failed')) + + await waitFor(() => { + expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).not.toBeInTheDocument() + }) + expect(mockTrackEvent).not.toHaveBeenCalledWith( + 'step_tour', + expect.objectContaining({ action: 'tour_skipped' }), + ) + + await user.click(screen.getByRole('button', { name: 'Skip tour' })) + + await waitFor(() => { + expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(2) + expect( + screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).toBeInTheDocument() + }) + }) + + it('shows a dismissible completion prompt at the bottom of the checklist after all tasks are complete', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home', 'studio', 'knowledge', 'integration'], + skipped: false, + }) + + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect( + await screen.findByRole('region', { name: 'Step-by-step Tour completed' }), + ).toBeInTheDocument() + expect(screen.getByText('You’re all set')).toBeInTheDocument() + expect( + screen.getByText('You’ve seen the essentials. Time to build something.'), + ).toBeInTheDocument() + + const dismissButton = screen.getByRole('button', { name: 'Dismiss' }) + await user.click(dismissButton) + + await waitFor(() => { + expect( + screen.queryByRole('region', { name: 'Step-by-step Tour completed' }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + await expectStepByStepTourPatch({ action: 'skip' }) + }) + + it('uses a three-step checklist when Learn Dify is disabled', async () => { + mockEnableLearnApp.value = false + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect(screen.queryByText('Try a Learn Dify lesson')).not.toBeInTheDocument() + expect(screen.getByText('Manage your apps in Studio')).toBeInTheDocument() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '3') + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0') + + await user.click(screen.getAllByRole('button', { name: 'Take a look' })[0]!) + + expect(mockRouterPush).toHaveBeenCalledWith('/apps') + expect(mockStepByStepTour.patchState).not.toHaveBeenCalled() + }) + + it('treats the three-step tour as complete when Learn Dify is disabled', async () => { + mockEnableLearnApp.value = false + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['studio', 'knowledge', 'integration'], + skipped: false, + }) + + renderStepByStepTourMount() + + expect( + await screen.findByRole('region', { name: 'Step-by-step Tour completed' }), + ).toBeInTheDocument() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuemax', '3') + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '3') + expect(screen.queryByText('Try a Learn Dify lesson')).not.toBeInTheDocument() + }) + + it('persists the collapsed shell mode across remounts', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + + const { unmount } = renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Minimize tour' })) + + await waitFor(() => { + expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('collapsed') + }) + expect(screen.getByRole('button', { name: 'Open step-by-step tour' })).toBeInTheDocument() + + unmount() + renderStepByStepTourMount() + + expect( + await screen.findByRole('button', { name: 'Open step-by-step tour' }), + ).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Open step-by-step tour' })) + + await waitFor(() => { + expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded') + }) + expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + }) + + it('shows the completion prompt expanded even when the saved shell mode is collapsed', async () => { + localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home', 'studio', 'knowledge', 'integration'], + skipped: false, + }) + + renderStepByStepTourMount() + + expect( + await screen.findByRole('region', { name: 'Step-by-step Tour completed' }), + ).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Open step-by-step tour' })).not.toBeInTheDocument() + }) + + it('hides expanded tour overlays while a blocking modal is open', async () => { + mockHasBlockingModalOpen.value = true + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + + renderStepByStepTourMount() + + await waitFor(() => { + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + expect(document.body.querySelector('[data-base-ui-portal]')).not.toBeInTheDocument() + }) + + it('keeps the minimized tour entry available while a blocking modal is open', async () => { + mockHasBlockingModalOpen.value = true + localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed') + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: [], + skipped: false, + }) + + renderStepByStepTourMount() + + expect( + await screen.findByRole('button', { name: 'Open step-by-step tour' }), + ).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument() + }) + + it('starts the integration guide instead of immediately completing the task', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + + renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Take a look' })) + + expect(mockRouterPush).toHaveBeenCalledWith('/integrations/model-provider') + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'task_started', + permission_variant: 'full', + task_id: 'integration', + }) + }) + + it('uses limited access integration guides when the workspace cannot manage integrations', async () => { + mockIsCurrentWorkspaceManager.value = false + mockCurrentWorkspaceRole.value = 'normal' + mockWorkspacePermissionKeys.value = [ + 'api_extension.manage', + 'plugin.install', + 'credential.use', + 'app_library.access', + ] + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + const targets = [ + STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, + STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, + STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, + STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, + ].map((targetName, index) => + createTourTarget(targetName, 80 + index * 8, { + height: 40, + left: 0, + width: 200, + }), + ) + + try { + renderStepByStepTourMount() + + expect( + await screen.findByText( + 'Browse models, tools, and data sources, and see how they are managed.', + ), + ).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Take a look' })) + + expect(mockRouterPush).toHaveBeenCalledWith('/integrations/model-provider') + expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument() + expect(screen.getByText('1 of 5')).toBeInTheDocument() + expect( + screen.getByText( + 'View model providers here, check model credentials, and see Message Credits. Installing or changing providers requires admin permission.', + ), + ).toBeInTheDocument() + } finally { + targets.forEach((target) => target.remove()) + } + }) + + it('completes Knowledge directly when the workspace has no Knowledge walkthrough permissions', async () => { + mockWorkspacePermissionKeys.value = ['app.create_and_management'] + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home', 'studio'], + skipped: false, + }) + + renderStepByStepTourMount() + + expect(await screen.findByText('Knowledge needs permission')).toBeInTheDocument() + expect(screen.queryByText('RESTRICTED')).not.toBeInTheDocument() + expect( + screen.getByText( + 'To create or manage knowledge bases, switch to a workspace where you have access or contact your admin.', + ), + ).toBeInTheDocument() + expect( + screen.getByRole('button', { name: 'Mark Knowledge needs permission complete' }), + ).toBeDisabled() + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'permission_fallback_shown', + permission_variant: 'no_knowledge_permission', + task_id: 'knowledge', + }) + + await user.click(screen.getByRole('button', { name: 'Got it' })) + + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'knowledge' }) + expect(mockRouterPush).not.toHaveBeenCalled() + }) + + it('starts the home guide against the Learn Dify target', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.home, 120, { + height: 180, + left: 40, + width: 360, + }) + + try { + renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Show me' })) + + expect(mockRouterPush).toHaveBeenCalledWith('/') + expect( + await screen.findByRole('region', { name: 'Pick a lesson to see how it works.' }), + ).toBeInTheDocument() + expect(screen.queryByText('Try a Learn Dify lesson')).not.toBeInTheDocument() + expect(screen.getByText('1 of 2')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument() + } finally { + target.remove() + } + }) + + it('uses no-create Learn Dify copy when the workspace cannot create apps', async () => { + mockWorkspacePermissionKeys.value = [] + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.home, 120, { + height: 180, + left: 40, + width: 360, + }) + + try { + renderStepByStepTourMount() + + expect(await screen.findByText('Browse Learn Dify')).toBeInTheDocument() + expect( + screen.getByText( + 'You can review lessons and see how Dify works here. Creating an app from a lesson requires a workspace where you have create permission, or help from an admin.', + ), + ).toBeInTheDocument() + await user.click(await screen.findByRole('button', { name: 'Show me' })) + + expect(mockRouterPush).toHaveBeenCalledWith('/') + expect(await screen.findByRole('region', { name: 'Browse Learn Dify' })).toBeInTheDocument() + expect( + screen.getByText( + 'You can review lessons and see how Dify works here. Creating an app from a lesson requires a workspace where you have create permission, or help from an admin.', + ), + ).toBeInTheDocument() + expect(screen.getByText('1 of 1')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Got it' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Got it' })) + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'home' }) + expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + } finally { + target.remove() + } + }) + + it('does not render a coachmark action for externally completed home guides', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.home, 120, { + height: 180, + left: 40, + width: 360, + }) + + try { + renderStepByStepTourMount() + + await user.click(await screen.findByRole('button', { name: 'Show me' })) + expect(await screen.findByText('Pick a lesson to see how it works.')).toBeInTheDocument() + expect(screen.getByText('1 of 2')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Show me' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Got it' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument() + } finally { + target.remove() + } + }) + + it('only allows users to uncomplete tasks from the checklist status control', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: ['home'], + skipped: false, + }) + + renderStepByStepTourMount() + + const completeStudioButton = await screen.findByRole('button', { + name: 'Mark Manage your apps in Studio complete', + }) + expect(completeStudioButton).toBeDisabled() + + await user.click(completeStudioButton) + + expect(mockStepByStepTour.patchState).not.toHaveBeenCalled() + + await user.click( + screen.getByRole('button', { name: 'Mark Try a Learn Dify lesson incomplete' }), + ) + + await expectStepByStepTourPatch({ action: 'uncomplete_task', task_id: 'home' }) + }) + + it('does not allow manually completing externally completed tasks from the checklist', async () => { + setStepByStepTourTestState({ + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: false, + completedTaskIds: [], + skipped: false, + }) + + renderStepByStepTourMount() + + const completeHomeButton = await screen.findByRole('button', { + name: 'Mark Try a Learn Dify lesson complete', + }) + expect(completeHomeButton).toBeDisabled() + + await user.click(completeHomeButton) + + expect(mockStepByStepTour.patchState).not.toHaveBeenCalled() + }) + + it('walks through Integration guides and syncs the Integrations section route', async () => { + mockPathname = '/integrations/model-provider' + setStepByStepTourTestState({ + activeTaskId: 'integration', + activeGuideIndex: 0, + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + const targets = [ + STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, + STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, + STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, + STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, + STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings, + ].map((targetName, index) => createTourTarget(targetName, 96 + index * 8)) + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument() + const [minimizedTourButton] = screen.getAllByRole('button', { + name: 'Open step-by-step tour', + }) + expect(minimizedTourButton).toBeInTheDocument() + expect(minimizedTourButton).not.toHaveClass('z-50') + expect(screen.getByText('1 of 6')).toBeInTheDocument() + await waitFor(() => { + expect(document.body.querySelector('[data-step-by-step-tour-backdrop]')).toBeInTheDocument() + }) + expect(document.body.querySelectorAll('[data-step-by-step-tour-blocker]')).toHaveLength(0) + expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument() + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'guide_shown', + guide_id: 'integration.model_provider', + task_id: 'integration', + }) + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument() + expect(screen.getByText('2 of 6')).toBeInTheDocument() + expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/built-in') + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'guide_completed', + guide_id: 'integration.model_provider', + task_id: 'integration', + }) + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument() + expect(screen.getByText('3 of 6')).toBeInTheDocument() + expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/mcp') + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument() + expect(screen.getByText('4 of 6')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument() + expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/data-source') + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument() + expect(screen.getByText('5 of 6')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument() + expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/trigger') + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Update Settings' })).toBeInTheDocument() + expect(screen.getByText('6 of 6')).toBeInTheDocument() + expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/built-in') + + await user.click(screen.getByRole('button', { name: 'Got it' })) + + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'integration' }) + expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect( + await screen.findByRole('region', { name: 'Step-by-step Tour completed' }), + ).toBeInTheDocument() + } finally { + targets.forEach((target) => target.remove()) + } + }) + + it('keeps the Integration update settings guide in the plan until its section target is rendered', async () => { + mockPathname = '/integrations/model-provider' + setStepByStepTourTestState({ + activeTaskId: 'integration', + activeGuideIndex: 0, + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + const targets = [ + STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, + STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, + STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, + STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, + ].map((targetName, index) => createTourTarget(targetName, 96 + index * 8)) + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument() + expect(screen.getByText('1 of 6')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument() + expect(screen.getByText('2 of 6')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument() + expect(screen.getByText('3 of 6')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument() + expect(screen.getByText('4 of 6')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument() + expect(screen.getByText('5 of 6')).toBeInTheDocument() + + const updateSettingsTarget = createTourTarget( + STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings, + 144, + ) + targets.push(updateSettingsTarget) + mockPathname = '/integrations/tools/built-in' + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Update Settings' })).toBeInTheDocument() + expect(screen.getByText('6 of 6')).toBeInTheDocument() + } finally { + targets.forEach((target) => target.remove()) + } + }) + + it('skips the optional Integration update settings guide when plugin preferences permission is unavailable', async () => { + mockPathname = '/integrations/model-provider' + mockWorkspacePermissionKeys.value = [ + 'app.create_and_management', + 'dataset.create_and_management', + 'dataset.external.connect', + 'plugin.model_config', + 'tool.manage', + 'mcp.manage', + 'api_extension.manage', + ] + setStepByStepTourTestState({ + activeTaskId: 'integration', + activeGuideIndex: 0, + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + const targets = [ + STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav, + STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav, + STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav, + STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav, + STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav, + ].map((targetName, index) => createTourTarget(targetName, 96 + index * 8)) + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument() + expect(screen.getByText('1 of 5')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument() + expect(screen.getByText('2 of 5')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument() + expect(screen.getByText('3 of 5')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument() + expect(screen.getByText('4 of 5')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument() + expect(screen.getByText('5 of 5')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'integration' }) + expect( + await screen.findByRole('region', { name: 'Step-by-step Tour completed' }), + ).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Update Settings' })).not.toBeInTheDocument() + } finally { + targets.forEach((target) => target.remove()) + } + }) + + it('skips the optional Learn Dify guide when the Studio empty section is not rendered', async () => { + mockPathname = '/apps' + setStepByStepTourTestState({ + activeTaskId: 'studio', + activeGuideIndex: 2, + activeGuideGroup: 'studioEmpty', + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home'], + skipped: false, + }) + const targets = [ + createTourTarget(STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate, 120), + createTourTarget(STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank, 210), + createTourTarget(STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL, 300), + ] + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Import a DSL file' })).toBeInTheDocument() + expect(screen.getByText('3 of 3')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'studio' }) + } finally { + targets.forEach((target) => target.remove()) + } + }) + + it('skips the optional app card action guide when no app card action target is rendered', async () => { + mockPathname = '/apps' + setStepByStepTourTestState({ + activeTaskId: 'studio', + activeGuideIndex: 0, + activeGuideGroup: 'studioWithApps', + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home'], + skipped: false, + }) + const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate, 72) + const highlightPart = createTourHighlightPart( + STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu, + { + height: 184, + left: 1088, + top: 120, + width: 280, + }, + ) + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Create a new app' })).toBeInTheDocument() + expect(screen.getByText('1 of 1')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Got it' })) + await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'studio' }) + } finally { + highlightPart.remove() + target.remove() + } + }) + + it('skips the active walkthrough without completing the task', async () => { + mockPathname = '/integrations/model-provider' + setStepByStepTourTestState({ + activeTaskId: 'integration', + activeGuideIndex: 1, + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav) + + try { + renderStepByStepTourMount() + + expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Got it' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument() + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'guide_shown', + guide_id: 'integration.tool_plugin', + task_id: 'integration', + }) + + await user.click(screen.getByRole('button', { name: 'Skip tour' })) + + expect( + mockStepByStepTour.patchState.mock.calls.map(([variables]) => variables.body), + ).not.toContainEqual(expect.objectContaining({ action: 'complete_task' })) + expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded') + expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }), + ).not.toBeInTheDocument() + expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { + action: 'guide_skipped', + guide_id: 'integration.tool_plugin', + task_id: 'integration', + }) + } finally { + target.remove() + } + }) + + it('keeps the minimized tour control visible when an active guide target is unavailable', async () => { + mockPathname = '/app/test-app/overview' + setStepByStepTourTestState({ + activeTaskId: 'integration', + manuallyEnabledWorkspaceIds: ['workspace-1'], + manuallyDisabledWorkspaceIds: [], + minimized: true, + completedTaskIds: ['home', 'studio', 'knowledge'], + skipped: false, + }) + + renderStepByStepTourMount() + + expect( + await screen.findAllByRole('button', { name: 'Open step-by-step tour' }), + ).not.toHaveLength(0) + }) +}) diff --git a/web/app/components/step-by-step-tour/__tests__/state.spec.ts b/web/app/components/step-by-step-tour/__tests__/state.spec.ts new file mode 100644 index 00000000000..59499aa564e --- /dev/null +++ b/web/app/components/step-by-step-tour/__tests__/state.spec.ts @@ -0,0 +1,464 @@ +import type { + StepByStepTourStatePatchPayload, + StepByStepTourStateResponse, +} from '@dify/contracts/api/console/onboarding/types.gen' +import { QueryClient } from '@tanstack/react-query' +import { createStore } from 'jotai' +import { queryClientAtom } from 'jotai-tanstack-query' +import { + activeStepByStepTourGuideGroupAtom, + activeStepByStepTourGuideIndexAtom, + activeStepByStepTourGuideIndexesAtom, + activeStepByStepTourTaskIdAtom, + advanceStepByStepTourGuideAtom, + completedStepByStepTourTaskIdsAtom, + completeStepByStepTourTaskAtom, + disableStepByStepTourForCurrentWorkspaceAtom, + enableStepByStepTourForCurrentWorkspaceAtom, + resetStepByStepTourSessionAtom, + resolveStepByStepTourGuideGroupAtom, + skipStepByStepTourAtom, + startStepByStepTourTaskAtom, + stepByStepTourEnabledForCurrentWorkspaceAtom, + stepByStepTourStateUpdatingAtom, + uncompleteStepByStepTourTaskAtom, +} from '../state' + +const stepByStepTourStateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const +let mockStepByStepTourState: StepByStepTourStateResponse + +const getStepByStepTourState = vi.fn(async () => mockStepByStepTourState) + +const applyPatch = ( + state: StepByStepTourStateResponse, + body: StepByStepTourStatePatchPayload, +): StepByStepTourStateResponse => { + switch (body.action) { + case 'complete_task': + return { + ...state, + completed_task_ids: body.task_id + ? Array.from(new Set([...(state.completed_task_ids ?? []), body.task_id])) + : state.completed_task_ids, + } + case 'uncomplete_task': + return { + ...state, + completed_task_ids: (state.completed_task_ids ?? []).filter( + (taskId) => taskId !== body.task_id, + ), + } + case 'skip': + return { ...state, skipped: true } + case 'enable_current_workspace': + return { + ...state, + skipped: false, + manually_enabled_workspace_ids: ['workspace-1'], + manually_disabled_workspace_ids: [], + } + case 'disable_current_workspace': + return { + ...state, + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: ['workspace-1'], + } + } +} + +const patchStepByStepTourState = vi.fn( + async ({ body }: { body: StepByStepTourStatePatchPayload }) => { + mockStepByStepTourState = applyPatch(mockStepByStepTourState, body) + return mockStepByStepTourState + }, +) + +vi.mock('@/config', () => ({ + IS_CLOUD_EDITION: true, +})) + +vi.mock('@/context/workspace-state', async () => { + const { atom } = await vi.importActual('jotai') + + return { currentWorkspaceIdAtom: atom('workspace-1') } +}) + +vi.mock('@/service/client', () => ({ + consoleQuery: { + onboarding: { + stepByStepTour: { + state: { + get: { + queryKey: () => stepByStepTourStateQueryKey, + queryOptions: () => ({ + queryKey: stepByStepTourStateQueryKey, + queryFn: getStepByStepTourState, + }), + }, + patch: { + mutationOptions: (options = {}) => ({ + mutationFn: patchStepByStepTourState, + ...options, + }), + }, + }, + }, + }, + }, +})) + +const createStepByStepTourState = ( + overrides: Partial = {}, +): StepByStepTourStateResponse => ({ + first_workspace_id: 'workspace-1', + skipped: false, + completed_task_ids: [], + manually_enabled_workspace_ids: [], + manually_disabled_workspace_ids: [], + updated_at: '2026-07-01T00:00:00Z', + ...overrides, +}) + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + mutations: { retry: false }, + queries: { retry: false, staleTime: Infinity }, + }, + }) + +const createDeferred = () => { + let reject!: (reason?: unknown) => void + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise + resolve = resolvePromise + }) + + return { promise, reject, resolve } +} + +describe('step-by-step tour state', () => { + let queryClient: QueryClient + let store: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mockStepByStepTourState = createStepByStepTourState() + queryClient = createQueryClient() + store = createStore() + store.set(queryClientAtom, queryClient) + queryClient.setQueryData(stepByStepTourStateQueryKey, mockStepByStepTourState) + }) + + it.each([ + ['first workspace', createStepByStepTourState(), true], + [ + 'manual enable', + createStepByStepTourState({ + first_workspace_id: null, + manually_enabled_workspace_ids: ['workspace-1'], + }), + true, + ], + [ + 'manual disable wins', + createStepByStepTourState({ manually_disabled_workspace_ids: ['workspace-1'] }), + false, + ], + ['skipped wins', createStepByStepTourState({ skipped: true }), false], + [ + 'unrelated workspace', + createStepByStepTourState({ first_workspace_id: 'workspace-2' }), + false, + ], + ])('derives eligibility for %s', (_case, state, expected) => { + queryClient.setQueryData(stepByStepTourStateQueryKey, state) + + expect(store.get(stepByStepTourEnabledForCurrentWorkspaceAtom)).toBe(expected) + }) + + it('keeps server state and the in-memory session as separate sources', () => { + queryClient.setQueryData( + stepByStepTourStateQueryKey, + createStepByStepTourState({ completed_task_ids: ['home'] }), + ) + + store.set(startStepByStepTourTaskAtom, { + taskId: 'studio', + guideGroup: 'studioWithApps', + guideIndexes: [0, 2], + }) + + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home']) + expect(store.get(activeStepByStepTourTaskIdAtom)).toBe('studio') + expect(store.get(activeStepByStepTourGuideIndexAtom)).toBe(0) + expect(store.get(activeStepByStepTourGuideGroupAtom)).toBe('studioWithApps') + expect(store.get(activeStepByStepTourGuideIndexesAtom)).toEqual([0, 2]) + expect( + queryClient.getQueryData(stepByStepTourStateQueryKey) + ?.completed_task_ids, + ).toEqual(['home']) + }) + + it('exposes exact session commands instead of a generic state setter', () => { + store.set(startStepByStepTourTaskAtom, { + taskId: 'studio', + guideGroup: 'studioEmpty', + }) + store.set(advanceStepByStepTourGuideAtom, { guideIndex: 2, guideIndexes: [0, 2] }) + store.set(resolveStepByStepTourGuideGroupAtom, { + taskId: 'studio', + guideGroup: 'studioWithApps', + }) + + expect(store.get(activeStepByStepTourGuideGroupAtom)).toBe('studioWithApps') + expect(store.get(activeStepByStepTourGuideIndexAtom)).toBe(0) + expect(store.get(activeStepByStepTourGuideIndexesAtom)).toBeUndefined() + + store.set(resetStepByStepTourSessionAtom) + + expect(store.get(activeStepByStepTourTaskIdAtom)).toBeUndefined() + }) + + it.each([ + [ + completeStepByStepTourTaskAtom, + { taskId: 'home' }, + { action: 'complete_task', task_id: 'home' }, + ], + [ + uncompleteStepByStepTourTaskAtom, + { taskId: 'home' }, + { action: 'uncomplete_task', task_id: 'home' }, + ], + [skipStepByStepTourAtom, undefined, { action: 'skip' }], + [ + enableStepByStepTourForCurrentWorkspaceAtom, + undefined, + { action: 'enable_current_workspace' }, + ], + [ + disableStepByStepTourForCurrentWorkspaceAtom, + undefined, + { action: 'disable_current_workspace' }, + ], + ] as const)( + 'sends the generated patch action through a domain command', + async (command, value, body) => { + if (value) store.set(command, value) + else store.set(command) + + await vi.waitFor(() => { + expect(patchStepByStepTourState.mock.calls[0]?.[0]).toEqual({ body }) + }) + }, + ) + + it('projects a command immediately and replaces the canonical cache after delayed success', async () => { + const deferred = createDeferred() + const onSuccess = vi.fn() + const onError = vi.fn() + patchStepByStepTourState.mockImplementationOnce(() => deferred.promise) + + const command = store.set(completeStepByStepTourTaskAtom, { + taskId: 'home', + onSuccess, + onError, + }) + + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home']) + }) + expect( + queryClient.getQueryData(stepByStepTourStateQueryKey) + ?.completed_task_ids, + ).toEqual([]) + expect(store.get(stepByStepTourStateUpdatingAtom)).toBe(true) + expect(onSuccess).not.toHaveBeenCalled() + expect(onError).not.toHaveBeenCalled() + + mockStepByStepTourState = applyPatch(mockStepByStepTourState, { + action: 'complete_task', + task_id: 'home', + }) + deferred.resolve(mockStepByStepTourState) + await command + + expect(onSuccess).toHaveBeenCalledWith(['home']) + expect(onError).not.toHaveBeenCalled() + expect(store.get(stepByStepTourStateUpdatingAtom)).toBe(false) + expect( + queryClient.getQueryData(stepByStepTourStateQueryKey) + ?.completed_task_ids, + ).toEqual(['home']) + }) + + it('keeps the canonical cache unchanged when a patch fails', async () => { + const onError = vi.fn() + patchStepByStepTourState.mockRejectedValueOnce(new Error('patch failed')) + + await store.set(completeStepByStepTourTaskAtom, { taskId: 'home', onError }) + + expect(onError).toHaveBeenCalledOnce() + expect( + queryClient.getQueryData(stepByStepTourStateQueryKey) + ?.completed_task_ids, + ).toEqual([]) + }) + + it('rolls back a failed projection before canonical reconciliation settles', async () => { + const reconciliation = createDeferred() + const onError = vi.fn() + getStepByStepTourState.mockImplementationOnce(() => reconciliation.promise) + patchStepByStepTourState.mockRejectedValueOnce(new Error('patch failed')) + const unsubscribe = store.sub(completedStepByStepTourTaskIdsAtom, () => {}) + + const command = store.set(completeStepByStepTourTaskAtom, { taskId: 'home', onError }) + + await vi.waitFor(() => { + expect(getStepByStepTourState).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledOnce() + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual([]) + expect(store.get(stepByStepTourStateUpdatingAtom)).toBe(false) + }) + + reconciliation.resolve(mockStepByStepTourState) + await command + unsubscribe() + }) + + it('refetches canonical state when the server commits before the response fails', async () => { + const error = new Error('response lost after commit') + const onError = vi.fn() + const unsubscribe = store.sub(completedStepByStepTourTaskIdsAtom, () => {}) + patchStepByStepTourState.mockImplementationOnce(async ({ body }) => { + mockStepByStepTourState = applyPatch(mockStepByStepTourState, body) + throw error + }) + + await store.set(completeStepByStepTourTaskAtom, { taskId: 'home', onError }) + + expect(onError).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home']) + expect( + queryClient.getQueryData(stepByStepTourStateQueryKey) + ?.completed_task_ids, + ).toEqual(['home']) + }) + unsubscribe() + }) + + it.each([false, true])( + 'serializes overlapping commands without dropping optimistic intent when observed=%s', + async (observeUpdating) => { + const first = createDeferred() + const second = createDeferred() + const firstOnSuccess = vi.fn() + const secondOnSuccess = vi.fn() + const unsubscribeState = store.sub(completedStepByStepTourTaskIdsAtom, () => {}) + const unsubscribeUpdating = observeUpdating + ? store.sub(stepByStepTourStateUpdatingAtom, () => {}) + : undefined + patchStepByStepTourState + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const firstCommand = store.set(completeStepByStepTourTaskAtom, { + taskId: 'home', + onSuccess: firstOnSuccess, + }) + const secondCommand = store.set(completeStepByStepTourTaskAtom, { + taskId: 'studio', + onSuccess: secondOnSuccess, + }) + + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home', 'studio']) + expect(patchStepByStepTourState).toHaveBeenCalledTimes(1) + }) + + mockStepByStepTourState = applyPatch(mockStepByStepTourState, { + action: 'complete_task', + task_id: 'home', + }) + first.resolve(mockStepByStepTourState) + + await vi.waitFor(() => { + expect(patchStepByStepTourState).toHaveBeenCalledTimes(2) + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home', 'studio']) + }) + + mockStepByStepTourState = applyPatch(mockStepByStepTourState, { + action: 'complete_task', + task_id: 'studio', + }) + second.resolve(mockStepByStepTourState) + await Promise.all([firstCommand, secondCommand]) + + expect(patchStepByStepTourState.mock.calls.map(([variables]) => variables.body)).toEqual([ + { action: 'complete_task', task_id: 'home' }, + { action: 'complete_task', task_id: 'studio' }, + ]) + expect(firstOnSuccess).toHaveBeenCalledOnce() + expect(secondOnSuccess).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home', 'studio']) + expect(store.get(stepByStepTourStateUpdatingAtom)).toBe(false) + }) + unsubscribeUpdating?.() + unsubscribeState() + }, + ) + + it('continues the command queue after an earlier command fails', async () => { + const first = createDeferred() + const second = createDeferred() + const firstOnSuccess = vi.fn() + const firstOnError = vi.fn() + const secondOnSuccess = vi.fn() + const unsubscribe = store.sub(completedStepByStepTourTaskIdsAtom, () => {}) + patchStepByStepTourState + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise) + + const firstCommand = store.set(completeStepByStepTourTaskAtom, { + taskId: 'home', + onSuccess: firstOnSuccess, + onError: firstOnError, + }) + const secondCommand = store.set(completeStepByStepTourTaskAtom, { + taskId: 'studio', + onSuccess: secondOnSuccess, + }) + + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['home', 'studio']) + }) + first.reject(new Error('first patch failed')) + + await vi.waitFor(() => { + expect(firstOnError).toHaveBeenCalledOnce() + expect(patchStepByStepTourState).toHaveBeenCalledTimes(2) + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['studio']) + }) + expect(getStepByStepTourState).not.toHaveBeenCalled() + + mockStepByStepTourState = applyPatch(mockStepByStepTourState, { + action: 'complete_task', + task_id: 'studio', + }) + second.resolve(mockStepByStepTourState) + await Promise.all([firstCommand, secondCommand]) + + expect(firstOnSuccess).not.toHaveBeenCalled() + expect(secondOnSuccess).toHaveBeenCalledWith(['studio']) + expect(getStepByStepTourState).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(store.get(completedStepByStepTourTaskIdsAtom)).toEqual(['studio']) + expect(store.get(stepByStepTourStateUpdatingAtom)).toBe(false) + }) + unsubscribe() + }) +}) diff --git a/web/app/components/step-by-step-tour/analytics.ts b/web/app/components/step-by-step-tour/analytics.ts new file mode 100644 index 00000000000..bc655eb7c8d --- /dev/null +++ b/web/app/components/step-by-step-tour/analytics.ts @@ -0,0 +1,74 @@ +'use client' + +import type { StepByStepTourTaskId } from './types' +import { trackEvent } from '@/app/components/base/amplitude' + +const STEP_BY_STEP_TOUR_ANALYTICS_EVENT = 'step_tour' + +export type StepByStepTourPermissionVariant = + | 'full' + | 'no_create' + | 'no_integration_permission' + | 'no_knowledge_permission' + +type StepByStepTourAction = + | 'guide_completed' + | 'guide_shown' + | 'guide_skipped' + | 'permission_fallback_shown' + | 'task_completed' + | 'task_reopened' + | 'task_started' + | 'tour_completed' + | 'tour_disabled' + | 'tour_enabled' + | 'tour_shown' + | 'tour_skipped' + +type StepByStepTourEntryPoint = 'first_workspace' | 'help_menu_enabled' | 'reenabled_after_skip' + +type StepByStepTourHomeOutcome = 'lesson_app_created' | 'lesson_opened' + +type StepByStepTourAnalyticsValue = number | string + +export type StepByStepTourAnalyticsProperties = { + action: StepByStepTourAction + completed_task_count?: number + entry_point?: StepByStepTourEntryPoint + guide_id?: string + home_outcome?: StepByStepTourHomeOutcome + permission_variant?: StepByStepTourPermissionVariant + task_id?: StepByStepTourTaskId + task_total?: number +} + +export const getStepByStepTourPermissionVariant = ({ + canCreateApp, + hasIntegrationWalkthroughPermissions, + hasKnowledgeWalkthroughPermissions, + taskId, +}: { + canCreateApp: boolean + hasIntegrationWalkthroughPermissions: boolean + hasKnowledgeWalkthroughPermissions: boolean + taskId: StepByStepTourTaskId +}): StepByStepTourPermissionVariant => { + if ((taskId === 'home' || taskId === 'studio') && !canCreateApp) return 'no_create' + + if (taskId === 'knowledge' && !hasKnowledgeWalkthroughPermissions) + return 'no_knowledge_permission' + + if (taskId === 'integration' && !hasIntegrationWalkthroughPermissions) + return 'no_integration_permission' + + return 'full' +} + +const toTrackEventProperties = (properties: StepByStepTourAnalyticsProperties) => + Object.fromEntries( + Object.entries(properties).filter(([, value]) => value !== undefined), + ) as Record + +export const trackStepByStepTourEvent = (properties: StepByStepTourAnalyticsProperties) => { + trackEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENT, toTrackEventProperties(properties)) +} diff --git a/web/app/components/step-by-step-tour/coachmark.tsx b/web/app/components/step-by-step-tour/coachmark.tsx new file mode 100644 index 00000000000..cbcd84ef20a --- /dev/null +++ b/web/app/components/step-by-step-tour/coachmark.tsx @@ -0,0 +1,387 @@ +'use client' + +import type { CSSProperties } from 'react' +import type { StepByStepTourGuide, StepByStepTourGuideInteractionPolicy } from './target-registry' +import type { + StepByStepTourCoachmarkPlacement, + StepByStepTourCoachmarkSize, +} from './use-coachmark-position' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { getStepByStepTourGuideKind, getStepByStepTourTargetSelector } from './target-registry' +import { useStepByStepTourCoachmarkPosition } from './use-coachmark-position' +import { useStepByStepTourTargetRect } from './use-target-rect' + +const HIGHLIGHT_PADDING = 4 +const DEFAULT_COACHMARK_SIZE: StepByStepTourCoachmarkSize = { + height: 158, + width: 352, +} +const STEP_BY_STEP_TOUR_PORTAL_ROOT_ATTRIBUTE = 'data-step-by-step-tour-portal-root' +const VERTICAL_ARROW_LENGTH = 28 +const VERTICAL_ARROW_DOT_SIZE = 12 +const VERTICAL_ARROW_DOT_OVERHANG = 3 +const VERTICAL_ARROW_OPTICAL_OFFSET = 1 +const VERTICAL_ARROW_EDGE_OFFSET = + VERTICAL_ARROW_LENGTH - + VERTICAL_ARROW_DOT_SIZE / 2 - + HIGHLIGHT_PADDING + + VERTICAL_ARROW_OPTICAL_OFFSET + +const VERTICAL_ARROW_DOT_STYLE: CSSProperties = { + top: -VERTICAL_ARROW_DOT_OVERHANG, +} +const SKIP_BUTTON_CLASS_NAME = + 'shrink-0 cursor-pointer rounded-md border-none bg-transparent p-0 text-left system-sm-regular text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid' + +const getStepByStepTourVerticalArrowStyle = ( + placement: StepByStepTourCoachmarkPlacement, + arrowStyle: CSSProperties, +): CSSProperties => ({ + ...arrowStyle, + ...(placement === 'top' + ? { bottom: -VERTICAL_ARROW_EDGE_OFFSET } + : { top: -VERTICAL_ARROW_EDGE_OFFSET }), +}) + +const useStepByStepTourPortalRoot = (enabled: boolean) => { + const portalRoot = useMemo(() => { + if (!enabled || typeof document === 'undefined') return null + + const portalElement = document.createElement('div') + portalElement.setAttribute(STEP_BY_STEP_TOUR_PORTAL_ROOT_ATTRIBUTE, '') + return portalElement + }, [enabled]) + + useLayoutEffect(() => { + if (!portalRoot) return + + document.body.appendChild(portalRoot) + return () => { + portalRoot.remove() + } + }, [portalRoot]) + + useLayoutEffect(() => { + if (portalRoot && portalRoot.nextSibling) document.body.appendChild(portalRoot) + }) + + return portalRoot +} + +type StepByStepTourCoachmarkGuide = Omit< + StepByStepTourGuide, + 'description' | 'learnMoreLabel' | 'primaryActionLabel' | 'title' +> & { + description: string + learnMoreHref?: string + learnMoreLabel: string + primaryActionLabel: string + title: string +} + +type StepByStepTourCoachmarkProps = { + guide: StepByStepTourCoachmarkGuide + targetElement: HTMLElement + placement?: StepByStepTourCoachmarkPlacement + stepLabel: string + skipLabel: string + interactionPolicy: StepByStepTourGuideInteractionPolicy + onSkip: () => void + onComplete: () => void +} + +export function StepByStepTourCoachmark({ + guide, + targetElement, + placement = 'bottom', + stepLabel, + skipLabel, + interactionPolicy, + onSkip, + onComplete, +}: StepByStepTourCoachmarkProps) { + const { + anchorRect, + highlightPartsReady, + highlightRect, + rectSettled, + targetElement: measuredTargetElement, + } = useStepByStepTourTargetRect(targetElement, guide.highlightPartSelectors) + const coachmarkRef = useRef(null) + const coachmarkSizeFrameRef = useRef(undefined) + const shouldUseLatePortalRoot = guide.portalOrder === 'afterOverlays' + const portalRoot = useStepByStepTourPortalRoot(shouldUseLatePortalRoot) + const [coachmarkSize, setCoachmarkSize] = + useState(DEFAULT_COACHMARK_SIZE) + const coachmarkPosition = useStepByStepTourCoachmarkPosition( + highlightRect, + placement, + anchorRect, + coachmarkSize, + ) + const stableOverlayRef = useRef< + | { + coachmarkPosition: ReturnType + guide: StepByStepTourCoachmarkGuide + highlightRect: typeof highlightRect + onComplete: () => void + onSkip: () => void + placement: StepByStepTourCoachmarkPlacement + skipLabel: string + interactionPolicy: StepByStepTourGuideInteractionPolicy + stepLabel: string + } + | undefined + >(undefined) + + const measuredRectMatchesGuide = + measuredTargetElement === targetElement && + targetElement.matches(getStepByStepTourTargetSelector(guide.target)) + + if (highlightPartsReady && rectSettled && measuredRectMatchesGuide) { + stableOverlayRef.current = { + coachmarkPosition, + guide, + highlightRect, + onComplete, + onSkip, + placement: coachmarkPosition.placement, + skipLabel, + interactionPolicy, + stepLabel, + } + } + + const stableOverlay = stableOverlayRef.current + const isActionGuide = stableOverlay + ? getStepByStepTourGuideKind(stableOverlay.guide) === 'action' + : false + const coachmarkMeasurementKey = stableOverlay + ? [ + stableOverlay.guide.target, + stableOverlay.guide.title, + stableOverlay.guide.description, + stableOverlay.guide.learnMoreHref, + stableOverlay.guide.learnMoreLabel, + stableOverlay.guide.primaryActionLabel, + stableOverlay.placement, + stableOverlay.stepLabel, + stableOverlay.skipLabel, + isActionGuide, + ].join('|') + : undefined + const highlightStyle: CSSProperties | undefined = stableOverlay + ? { + height: stableOverlay.highlightRect.height + HIGHLIGHT_PADDING * 2, + left: stableOverlay.highlightRect.left - HIGHLIGHT_PADDING, + top: stableOverlay.highlightRect.top - HIGHLIGHT_PADDING, + width: stableOverlay.highlightRect.width + HIGHLIGHT_PADDING * 2, + } + : undefined + + const syncCoachmarkSize = useCallback((element: HTMLElement) => { + const rect = element.getBoundingClientRect() + const nextSize = { + height: Math.ceil(rect.height), + width: Math.ceil(rect.width), + } + if (nextSize.height <= 0 || nextSize.width <= 0) return + + setCoachmarkSize((currentSize) => + currentSize.height === nextSize.height && currentSize.width === nextSize.width + ? currentSize + : nextSize, + ) + }, []) + + useLayoutEffect(() => { + const element = coachmarkRef.current + if (!element) return + + coachmarkSizeFrameRef.current = window.requestAnimationFrame(() => { + coachmarkSizeFrameRef.current = undefined + syncCoachmarkSize(element) + }) + + return () => { + if (coachmarkSizeFrameRef.current !== undefined) { + window.cancelAnimationFrame(coachmarkSizeFrameRef.current) + coachmarkSizeFrameRef.current = undefined + } + } + }, [coachmarkMeasurementKey, syncCoachmarkSize]) + + if (typeof document === 'undefined') return null + if (shouldUseLatePortalRoot && !portalRoot) return null + + const targetBlockerStyles: CSSProperties[] = + stableOverlay && highlightStyle + ? [ + { + height: highlightStyle.top, + left: 0, + right: 0, + top: 0, + }, + { + bottom: 0, + left: 0, + top: Number(highlightStyle.top) + Number(highlightStyle.height), + right: 0, + }, + { + height: highlightStyle.height, + left: 0, + top: highlightStyle.top, + width: highlightStyle.left, + }, + { + height: highlightStyle.height, + left: Number(highlightStyle.left) + Number(highlightStyle.width), + right: 0, + top: highlightStyle.top, + }, + ] + : [] + + return createPortal( + <> + {stableOverlay?.interactionPolicy === 'target-only' ? ( + targetBlockerStyles.map((style, index) => ( +