feat(web): add step-by-step tour shell (#38785)

Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Jingyi 2026-07-20 02:23:25 -07:00 committed by GitHub
parent e77ee82526
commit cb64446fa3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 10409 additions and 283 deletions

View File

@ -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):
"""

View File

@ -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",

View File

@ -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,
),
)

View File

@ -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")

View File

@ -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",

59
api/models/onboarding.py Normal file
View File

@ -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(),
)

View File

@ -7787,6 +7787,30 @@ Initiate OAuth login process
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [OAuthProviderTokenResponse](#oauthprovidertokenresponse)<br> |
### [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)<br> |
### [PATCH] /onboarding/step-by-step-tour/state
Update account-level Step-by-step Tour state
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [StepByStepTourStatePatchPayload](#stepbysteptourstatepatchpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)<br> |
### [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, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*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, <br>**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, <br>**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 |

View File

@ -1577,6 +1577,7 @@ Default configuration for form inputs.
| enable_learn_app | boolean, <br>**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 |

View File

@ -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]:

View File

@ -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

View File

@ -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"})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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<string>
manually_enabled_workspace_ids?: Array<string>
skipped?: boolean
updated_at?: string | null
}
export type StepByStepTourStatePatchPayload = {
action:
| 'complete_task'
| 'disable_current_workspace'
| 'enable_current_workspace'
| 'skip'
| 'uncomplete_task'
task_id?: 'home' | 'integration' | 'knowledge' | 'studio' | null
}
export type GetOnboardingStepByStepTourStateData = {
body?: never
path?: never
query?: never
url: '/onboarding/step-by-step-tour/state'
}
export type GetOnboardingStepByStepTourStateResponses = {
200: StepByStepTourStateResponse
}
export type GetOnboardingStepByStepTourStateResponse =
GetOnboardingStepByStepTourStateResponses[keyof GetOnboardingStepByStepTourStateResponses]
export type PatchOnboardingStepByStepTourStateData = {
body: StepByStepTourStatePatchPayload
path?: never
query?: never
url: '/onboarding/step-by-step-tour/state'
}
export type PatchOnboardingStepByStepTourStateResponses = {
200: StepByStepTourStateResponse
}
export type PatchOnboardingStepByStepTourStateResponse =
PatchOnboardingStepByStepTourStateResponses[keyof PatchOnboardingStepByStepTourStateResponses]

View File

@ -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

View File

@ -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: () =>

View File

@ -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,

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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),

View File

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

After

Width:  |  Height:  |  Size: 282 B

View File

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

View File

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

View File

@ -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 (
<DropdownMenu modal={false}>
<DropdownMenu modal={false} open={menu.open} onOpenChange={menu.onOpenChange}>
<DropdownMenuTrigger
render={
<Button
data-step-by-step-tour-target={stepByStepTourTarget}
variant="primary"
size="medium"
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
@ -38,7 +52,16 @@ export function CreateAppDropdown({
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-70 p-0">
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: menu.controlled,
highlightPart: menu.controlled ? stepByStepTourHighlightPart : undefined,
interactionMode: menu.controlled ? 'presentation' : 'interactive',
popupClassName: 'w-70 p-0',
})}
>
<div className="py-1">
<DropdownMenuItem
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"

View File

@ -77,6 +77,7 @@ const defaultProviderContext = {
}
const defaultModalContext: ModalContextState = {
hasBlockingModalOpen: false,
setShowAccountSettingModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,

View File

@ -63,6 +63,7 @@ const mockSetShowAccountSettingModal = vi.fn()
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),

View File

@ -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<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => {
const { isOpen } = useDropdownMenuContext()
if (!isOpen) return null
return (
<div
data-testid="dropdown-menu-content"
role="menu"
className={[className, popupClassName].filter(Boolean).join(' ')}
>
{children}
<div data-testid="dropdown-menu-positioner" {...positionerProps}>
<div
data-testid="dropdown-menu-content"
role="menu"
className={[className, popupClassName].filter(Boolean).join(' ')}
{...popupProps}
>
{children}
</div>
</div>
)
},
@ -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(
<AppCard
app={mockApp}
stepByStepTourActionMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
}
stepByStepTourActionMenuOpen
/>,
)
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
expect(
screen.getByRole('menuitem', { name: 'app.editApp', hidden: true }),
).toBeInTheDocument()
expect(screen.getByTestId('dropdown-menu-positioner')).toHaveAttribute(
'data-step-by-step-tour-highlight-part',
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu,
)
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
})
})
describe('Open in Explore - No App Found', () => {

View File

@ -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(
<ConsoleQueryWrapper>
<List onCreateLearnDify={options.onCreateLearnDify} onTryLearnDify={options.onTryLearnDify} />
<JotaiProvider store={store}>
<List
onCreateLearnDify={options.onCreateLearnDify}
onTryLearnDify={options.onTryLearnDify}
/>
</JotaiProvider>
</ConsoleQueryWrapper>,
{ 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', () => {

View File

@ -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<EnvironmentVariable[]>([])
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 ? (
<AppCardOperationsMenuContent

View File

@ -30,6 +30,9 @@ type AppListHeaderFiltersProps = {
onImportDSL: () => 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 (
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
@ -85,6 +90,9 @@ export function AppListHeaderFilters({
onCreateBlank={onCreateBlank}
onCreateTemplate={onCreateTemplate}
onImportDSL={onImportDSL}
stepByStepTourControlledOpen={stepByStepTourCreateMenuOpen}
stepByStepTourTarget={stepByStepTourCreateMenuTarget}
stepByStepTourHighlightPart={stepByStepTourCreateMenuHighlightPart}
/>
)}
</div>

View File

@ -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 <FilterEmptyState title={message ?? t(($) => $['filterEmpty.noApps'], { ns: 'app' })} />
return (
<FilterEmptyState
title={message ?? t(($) => $['filterEmpty.noApps'], { ns: 'app' })}
contentDataAttributes={
stepByStepTourTarget ? { 'data-step-by-step-tour-target': stepByStepTourTarget } : undefined
}
/>
)
}
export default React.memo(Empty)

View File

@ -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 (
<Link href={props.href} className={className}>
<Link
href={props.href}
className={className}
data-step-by-step-tour-target={props.stepByStepTourTarget}
>
<ActionCardContent
badge={props.badge}
badgeVariant={props.badgeVariant}
@ -158,7 +163,12 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
}
return (
<button type="button" className={className} onClick={props.onClick}>
<button
type="button"
className={className}
data-step-by-step-tour-target={props.stepByStepTourTarget}
onClick={props.onClick}
>
<ActionCardContent
badge={props.badge}
badgeVariant={props.badgeVariant}

View File

@ -5,6 +5,7 @@ import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { useTranslation } from 'react-i18next'
import LearnDify from '@/app/components/explore/learn-dify'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import FirstEmptyActionCard from './action-card'
const EMPTY_PLACEHOLDER_CARD_IDS = Array.from(
@ -17,6 +18,7 @@ type EmptyCreateAction = {
icon: ReactNode
title: string
description: string
target: string
onClick: () => void
}
@ -46,6 +48,7 @@ function FirstEmptyState({
title: t(($) => $['newApp.startFromTemplate'], { ns: 'app' }),
description: t(($) => $['firstEmpty.templateDescription'], { ns: 'app' }),
onClick: onCreateTemplate,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate,
},
{
id: 'blank',
@ -53,6 +56,7 @@ function FirstEmptyState({
title: t(($) => $['newApp.startFromBlank'], { ns: 'app' }),
description: t(($) => $['firstEmpty.blankDescription'], { ns: 'app' }),
onClick: onCreateBlank,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank,
},
{
id: 'dsl',
@ -60,6 +64,7 @@ function FirstEmptyState({
title: t(($) => $.importDSL, { ns: 'app' }),
description: t(($) => $['firstEmpty.importDescription'], { ns: 'app' }),
onClick: onImportDSL,
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL,
},
]
@ -95,6 +100,7 @@ function FirstEmptyState({
description={action.description}
icon={action.icon}
onClick={action.onClick}
stepByStepTourTarget={action.target}
title={action.title}
visualStyle="list"
/>
@ -111,6 +117,7 @@ function FirstEmptyState({
description={actions[2]!.description}
icon={actions[2]!.icon}
onClick={actions[2]!.onClick}
stepByStepTourTarget={actions[2]!.target}
title={actions[2]!.title}
visualStyle="list"
/>
@ -119,16 +126,18 @@ function FirstEmptyState({
</section>
</div>
{showLearnDify && (
<LearnDify
canCreate
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
onCreate={onCreateLearnDify}
onTry={onTryLearnDify}
showDescription
title={t(($) => $['firstEmpty.learnDifyTitle'], { ns: 'app' })}
/>
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify}>
<LearnDify
canCreate
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
dismissible={false}
itemLimit={4}
onCreate={onCreateLearnDify}
onTry={onTryLearnDify}
showDescription
title={t(($) => $['firstEmpty.learnDifyTitle'], { ns: 'app' })}
/>
</div>
)}
</div>
)

View File

@ -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<GetAppsData['query']>
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
@ -71,6 +82,10 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
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 (
<>
<div
@ -269,6 +331,13 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro
onImportDSL={openCreateFromDSLModal}
onOpenTagManagement={() => setShowTagManagementModal(true)}
showCreateButton={canCreateApp}
stepByStepTourCreateMenuOpen={
activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined
}
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate}
stepByStepTourCreateMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu
}
/>
</StudioListHeader>
{showFirstEmptyState ? (
@ -283,7 +352,25 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro
) : (
<>
{starredApps.length > 0 && (
<StarredAppList apps={starredApps} onRefresh={refreshAppLists} />
<StarredAppList
apps={starredApps}
onRefresh={refreshAppLists}
stepByStepTourCardTarget={
shouldHighlightStepByStepTourStarredAppRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
: undefined
}
stepByStepTourCardHighlightPart={
shouldHighlightStepByStepTourStarredAppRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
: undefined
}
stepByStepTourHighlightedCardCount={
shouldHighlightStepByStepTourStarredAppRow
? STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT
: 0
}
/>
)}
<div
className={cn(
@ -294,17 +381,46 @@ function List({ controlRefreshList = 0, onCreateLearnDify, onTryLearnDify }: Pro
{showSkeleton ? (
<AppCardSkeleton count={6} />
) : hasAnyApp ? (
apps.map((app) => (
apps.map((app, index) => (
<AppCard
key={app.id}
app={app}
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
onRefresh={refreshAppLists}
onOpenTagManagement={() => setShowTagManagementModal(true)}
stepByStepTourActionMenuOpen={
index === 0 ? shouldOpenStepByStepTourAppCardActionMenu : undefined
}
stepByStepTourCardTarget={
index === 0
? shouldHighlightStepByStepTourAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
: canCreateApp
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
: undefined
: undefined
}
stepByStepTourCardHighlightPart={
index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT &&
shouldHighlightStepByStepTourAllAppsRow
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard
: undefined
}
stepByStepTourActionMenuHighlightPart={
index === 0 && shouldOpenStepByStepTourAppCardActionMenu
? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
: undefined
}
/>
))
) : (
<Empty />
<Empty
stepByStepTourTarget={
showNoCreateEmptyState
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty
: undefined
}
/>
)}
{isFetchingNextPage && <AppCardSkeleton count={3} />}
</div>

View File

@ -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 (
<div className="group relative">
{isPreviewOnly ? (
<article
<div
role="button"
tabIndex={0}
aria-disabled="true"
aria-label={app.name}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
{cardContent}
</article>
</div>
) : (
<Link href={href} className={cardClassName}>
<Link
href={href}
data-step-by-step-tour-target={stepByStepTourCardTarget}
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
className={cardClassName}
>
{cardContent}
</Link>
)}

View File

@ -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) {
<>
<SectionDivider label={t(($) => $['studio.starred'], { ns: 'app' })} />
<div className={APP_LIST_GRID_CLASS_NAME}>
{apps.map((app) => (
<StarredAppCard key={app.id} app={app} onRefresh={onRefresh} />
{apps.map((app, index) => (
<StarredAppCard
key={app.id}
app={app}
onRefresh={onRefresh}
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
stepByStepTourCardHighlightPart={
index < stepByStepTourHighlightedCardCount
? stepByStepTourCardHighlightPart
: undefined
}
/>
))}
</div>
<SectionDivider label={t(($) => $['studio.allApps'], { ns: 'app' })} />

View File

@ -548,7 +548,7 @@ describe('AgentRosterResponseContent', () => {
render(<AgentRosterResponseContent item={item} content={item.content} />)
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()

View File

@ -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 (
<div
className={cn(
@ -21,7 +26,7 @@ const FilterEmptyState = ({ title, className }: FilterEmptyStateProps) => {
))}
<div className="absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" />
<div className="absolute inset-0 flex items-center justify-center overflow-hidden p-2">
<div className="flex flex-col items-center justify-center gap-3">
<div {...contentDataAttributes} className="flex flex-col items-center justify-center gap-3">
<div className="flex size-14 items-center justify-center rounded-lg">
<div className="flex size-full min-w-px items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1">
<span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" />

View File

@ -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 }) => (
<article data-testid={`dataset-card-${dataset.id}`}>{dataset.name}</article>
default: ({
dataset,
stepByStepTourActionMenuHighlightPart,
stepByStepTourActionMenuOpen,
stepByStepTourCardTarget,
}: {
dataset: DataSet
stepByStepTourActionMenuHighlightPart?: string
stepByStepTourActionMenuOpen?: boolean
stepByStepTourCardTarget?: string
}) => (
<article data-testid={`dataset-card-${dataset.id}`}>
{dataset.name}
<span data-testid={`dataset-card-target-${dataset.id}`}>{stepByStepTourCardTarget}</span>
<span data-testid={`dataset-card-menu-open-${dataset.id}`}>
{String(stepByStepTourActionMenuOpen)}
</span>
<span data-testid={`dataset-card-highlight-${dataset.id}`}>
{stepByStepTourActionMenuHighlightPart}
</span>
</article>
),
}))
@ -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(
<Datasets
{...defaultProps}
stepByStepTourActionMenuOpen
stepByStepTourActionMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu
}
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
/>,
)
expect(screen.getByTestId('dataset-card-target-dataset-1')).toHaveTextContent(
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard,
)
expect(screen.getByTestId('dataset-card-menu-open-dataset-1')).toHaveTextContent('true')
expect(screen.getByTestId('dataset-card-highlight-dataset-1')).toHaveTextContent(
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu,
)
expect(screen.getByTestId('dataset-card-target-dataset-2')).toBeEmptyDOMElement()
expect(screen.getByTestId('dataset-card-menu-open-dataset-2')).toHaveTextContent('undefined')
expect(screen.getByTestId('dataset-card-highlight-dataset-2')).toBeEmptyDOMElement()
})
it('should render empty element when there are no datasets', () => {
render(
<Datasets

View File

@ -1,23 +1,42 @@
import { render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import DatasetListHeader from '../header'
vi.mock('@langgenius/dify-ui/button', () => ({
Button: ({ children }: { children: React.ReactNode }) => (
<button type="button">{children}</button>
Button: ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" className={className} {...props}>
{children}
</button>
),
}))
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuContent: ({
children,
popupProps,
positionerProps,
}: {
children: React.ReactNode
popupProps?: React.HTMLAttributes<HTMLDivElement>
positionerProps?: React.HTMLAttributes<HTMLDivElement>
}) => (
<div {...positionerProps}>
<div role="menu" {...popupProps}>
{children}
</div>
</div>
),
DropdownMenuItem: ({
children,
className,
onClick,
}: {
children: React.ReactNode
className?: string
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
<button type="button" role="menuitem" className={className} onClick={onClick}>
{children}
</button>
),
@ -56,16 +75,32 @@ const defaultProps = {
}
describe('DatasetListHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses the updated create menu labels and pipeline icon', () => {
render(<DatasetListHeader {...defaultProps} />)
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(<DatasetListHeader {...defaultProps} canCreateDataset={false} />)
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(
<DatasetListHeader
{...defaultProps}
stepByStepTourCreateMenuOpen
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu
}
/>,
)
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate,
)
expect(screen.getByText('dataset.firstEmpty.createTitle')).toBeInTheDocument()
expect(
screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true }),
).toBeInTheDocument()
const createMenuHighlightPart = document.body.querySelector(
`[data-step-by-step-tour-highlight-part="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}"]`,
)
expect(createMenuHighlightPart).toBeInTheDocument()
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
})
it('keeps the tour-opened create menu as presentation only', () => {
render(
<DatasetListHeader
{...defaultProps}
stepByStepTourCreateMenuOpen
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
stepByStepTourCreateMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu
}
/>,
)
fireEvent.click(
screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true }),
)
expect(defaultProps.onCreateDataset).not.toHaveBeenCalled()
})
})

View File

@ -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
}) => (
<div
data-testid="operations-dropdown"
data-has-open-access-config={typeof openAccessConfig === 'function'}
data-step-by-step-tour-highlight-part={stepByStepTourHighlightPart}
data-step-by-step-tour-open={String(stepByStepTourOpen)}
/>
),
}))
@ -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(
<DatasetCard
dataset={dataset}
stepByStepTourActionMenuHighlightPart={
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu
}
stepByStepTourActionMenuOpen
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
/>,
)
expect(
container.querySelector(
`[data-step-by-step-tour-target="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}"]`,
),
).toBeInTheDocument()
expect(screen.getByTestId('operations-dropdown')).toHaveAttribute(
'data-step-by-step-tour-highlight-part',
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu,
)
expect(screen.getByTestId('operations-dropdown')).toHaveAttribute(
'data-step-by-step-tour-open',
'true',
)
})
})
// Integration tests for Description component
describe('Description', () => {
describe('Rendering', () => {

View File

@ -1,5 +1,5 @@
import type { DataSet } from '@/models/datasets'
import { 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<typeof renderWithConsoleQuery>[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(<OperationsDropdown {...defaultProps} />)
const trigger = screen.getByLabelText('Dataset operations')
const event = createEvent.click(trigger)
fireEvent(trigger, event)
expect(event.defaultPrevented).toBe(true)
})
it('should keep menu item clicks from bubbling to the card while running the item action', async () => {
const detectIsUsedByApp = vi.fn()
const onCardClick = vi.fn()
render(
<div role="button" tabIndex={0} onClick={onCardClick} onKeyDown={noopKeyboardHandler}>
<OperationsDropdown {...defaultProps} detectIsUsedByApp={detectIsUsedByApp} />
</div>,
)
fireEvent.click(screen.getByLabelText('Dataset operations'))
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
expect(onCardClick).not.toHaveBeenCalled()
})
it('should keep the tour-opened operations menu open when its trigger is clicked', async () => {
render(
<OperationsDropdown
{...defaultProps}
stepByStepTourHighlightPart="knowledge-card-actions-menu"
stepByStepTourOpen
/>,
)
expect(await screen.findByText('common.operation.edit')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Dataset operations'))
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('pointer-events-none')
})
it('should keep tour-opened operations menu items from running actions', async () => {
const detectIsUsedByApp = vi.fn()
render(
<OperationsDropdown
{...defaultProps}
detectIsUsedByApp={detectIsUsedByApp}
stepByStepTourHighlightPart="knowledge-card-actions-menu"
stepByStepTourOpen
/>,
)
fireEvent.click(
await screen.findByRole('menuitem', { name: 'common.operation.delete', hidden: true }),
)
expect(detectIsUsedByApp).not.toHaveBeenCalled()
expect(screen.getByRole('menu', { hidden: true })).toBeInTheDocument()
})
it('should pass openRenameModal to Operations', () => {
const openRenameModal = vi.fn()
render(<OperationsDropdown {...defaultProps} openRenameModal={openRenameModal} />)

View File

@ -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()
}}
>
<span className="i-ri-more-fill size-5 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" popupClassName="min-w-[186px]">
<DropdownMenuContent
placement="bottom-end"
popupClassName="min-w-[186px]"
{...getStepByStepTourDropdownMenuContentProps({
highlightPart: stepByStepTourHighlightPart,
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
})}
>
<Operations
showEdit={datasetACLCapabilities.canEdit}
showDelete={datasetACLCapabilities.canDelete}

View File

@ -29,9 +29,19 @@ type DatasetCardProps = {
dataset: DataSet
onSuccess?: () => 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}
/>
)}
</div>

View File

@ -19,6 +19,9 @@ type Props = Readonly<{
isPlaceholderData: ReturnType<typeof useDatasetList>['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 ? (
<DatasetCardSkeleton label={t(($) => $.loading, { ns: 'common' })} />
) : (
datasets.map((dataset) => (
datasets.map((dataset, index) => (
<DatasetCard
key={dataset.id}
dataset={dataset}
onSuccess={invalidDatasetList}
onOpenTagManagement={onOpenTagManagement}
stepByStepTourActionMenuHighlightPart={
index === 0 && stepByStepTourActionMenuOpen
? stepByStepTourActionMenuHighlightPart
: undefined
}
stepByStepTourActionMenuOpen={index === 0 ? stepByStepTourActionMenuOpen : undefined}
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
/>
))
)}

View File

@ -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
}) => (
<a href={href} className={className}>
...props
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
<a href={href} className={className} {...props}>
{children}
</a>
),
@ -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(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.createTitle/ })).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate,
)
expect(
screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }),
).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline,
)
expect(screen.getByRole('link', { name: /dataset\.connectDataset/ })).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect,
)
})
it('lays out placeholder cards with auto-fill grid columns', () => {
const { container } = render(
<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />,
)
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none')).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(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset={false} />)

View File

@ -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"
/>

View File

@ -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 (
<div className="sticky top-0 z-10 flex flex-col gap-[14px] bg-background-body px-8 pt-4 pb-2">
@ -103,10 +116,19 @@ const DatasetListHeader = ({
</div>
<div className="flex items-center gap-2">
{showCreateMenu && (
<DropdownMenu modal={false}>
<DropdownMenu
modal={false}
open={createMenu.open}
onOpenChange={createMenu.onOpenChange}
>
<DropdownMenuTrigger
render={
<Button variant="primary" size="medium" className="gap-0.5 px-2 shadow-xs">
<Button
variant="primary"
size="medium"
className="gap-0.5 px-2 shadow-xs"
data-step-by-step-tour-target={stepByStepTourCreateMenuTarget}
>
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
<span className="pl-1">
{t(($) => $['operation.create'], { ns: 'common' })}
@ -115,7 +137,18 @@ const DatasetListHeader = ({
</Button>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-80">
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
{...getStepByStepTourDropdownMenuContentProps({
disableMotion: createMenu.controlled,
highlightPart: createMenu.controlled
? stepByStepTourCreateMenuHighlightPart
: undefined,
interactionMode: createMenu.controlled ? 'presentation' : 'interactive',
popupClassName: 'w-80',
})}
>
{canCreateDataset && (
<>
<DropdownMenuItem

View File

@ -1,10 +1,20 @@
'use client'
import { useBoolean, useDebounceFn } from 'ahooks'
import { useAtomValue } from 'jotai'
import { useAtomValue, useSetAtom } from 'jotai'
// Libraries
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { useExternalApiPanel } from '@/context/external-api-panel-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { isCurrentWorkspaceOwnerAtom } from '@/context/workspace-state'
@ -87,6 +97,41 @@ const List = () => {
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 (
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
@ -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
}
/>
<DatasetFirstEmptyState
canConnectExternalDataset={canConnectExternalDataset}
@ -132,6 +184,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
}
/>
<Datasets
datasetList={datasetListQuery.data}
@ -149,6 +208,11 @@ const List = () => {
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}
/>
</>
)}

View File

@ -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> = {},
): 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> = {},
): 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<StepByStepTourStateResponse> => {
switch (body.action) {
case 'complete_task':
state = {
...state,
completed_task_ids:
body.task_id && !state.completed_task_ids?.includes(body.task_id)
? [...(state.completed_task_ids ?? []), body.task_id]
: state.completed_task_ids,
}
break
case 'uncomplete_task':
state = {
...state,
completed_task_ids: (state.completed_task_ids ?? []).filter(
(taskId) => taskId !== body.task_id,
),
}
break
case 'skip':
state = {
...state,
skipped: true,
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(
(id) => id !== 'workspace-1',
),
}
break
case 'enable_current_workspace':
state = {
...state,
skipped: false,
manually_enabled_workspace_ids: Array.from(
new Set([...(state.manually_enabled_workspace_ids ?? []), 'workspace-1']),
),
manually_disabled_workspace_ids: (state.manually_disabled_workspace_ids ?? []).filter(
(id) => id !== 'workspace-1',
),
}
break
case 'disable_current_workspace':
state = {
...state,
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(
(id) => id !== 'workspace-1',
),
manually_disabled_workspace_ids: Array.from(
new Set([...(state.manually_disabled_workspace_ids ?? []), 'workspace-1']),
),
}
break
}
return state
},
)
return {
get state() {
return state
},
get uiState() {
return uiState
},
patchState,
reset() {
state = createState()
uiState = createUiState()
patchState.mockClear()
},
setState(overrides: Partial<StepByStepTourStateResponse> = {}) {
state = createState(overrides)
},
setUiState(overrides: Partial<StepByStepTourTestUiState> = {}) {
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
}) => (
<div data-testid="try-app-panel">
<button data-testid="try-app-create" onClick={onCreate}>
create
</button>
{canCreate && (
<button
data-testid="try-app-create"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
create
</button>
)}
<button data-testid="try-app-close" onClick={onClose}>
close
</button>
@ -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 }) => (
<JotaiProvider store={jotaiStore}>
<ConsoleQueryWrapper>{children}</ConsoleQueryWrapper>
<ConsoleQueryWrapper>
<StepByStepTourSessionFixture initialState={mockStepByStepTour.uiState}>
{children}
{options.extra}
</StepByStepTourSessionFixture>
</ConsoleQueryWrapper>
</JotaiProvider>
)
const rendered = renderWithNuqs(
@ -390,6 +583,16 @@ const renderAppList = (
return { ...rendered, queryClient }
}
function SkipHomeGuideProbe() {
const resetStepByStepTourSession = useSetAtom(resetStepByStepTourSessionAtom)
return (
<button type="button" data-testid="skip-home-guide" onClick={resetStepByStepTourSession}>
skip home guide
</button>
)
}
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: <SkipHomeGuideProbe />,
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()
})
})
})

View File

@ -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 (
<>
<ContinueWork apps={continueWorkApps} />
<LearnDify canCreate={canCreate} className="pb-0" onCreate={onCreate} onTry={onTry} />
<LearnDify
canCreate={canCreate}
className="pb-0"
forceVisible={forceShowLearnDify}
onCreate={onCreate}
onTry={onTry}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home}
/>
</>
)
}

View File

@ -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 }) => {
<ExploreRecommendations
canCreate={canCreateApp}
continueWorkApps={homeQueries.continueWorkApps}
forceShowLearnDify={shouldForceShowLearnDifyForTour}
onCreate={handleCreateFromLearnDify}
onTry={handleTryApp}
onTry={handleTryAppFromLearnDify}
/>
<ExploreAppListHeader
@ -323,13 +538,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
show={isShowCreateModal}
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
onHide={handleCreateModalHide}
/>
)}
{showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onCancel={handleCancelDSLConfirm}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
@ -339,7 +554,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
<TryApp
appId={currentTryApp?.appId || ''}
app={currentTryApp?.app}
canCreate={canCreateApp}
categories={currentTryApp?.app?.categories}
createButtonStepByStepTourTarget={
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate
: undefined
}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>

View File

@ -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 => ({
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(
<LearnDify forceVisible={forceVisible} stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home} />,
{ wrapper },
)
}
describe('LearnDify', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockLearnDifyApps = [createApp()]
mockLearnDifyLoading = false
})
it('should stay hidden when the user hidden preference is set', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify()
expect(
screen.queryByRole('heading', { name: 'explore.learnDify.title' }),
).not.toBeInTheDocument()
})
it('should show hidden content when forceVisible is set for the step tour', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify({ forceVisible: true })
const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' })
expect(learnDifyHeading).toBeInTheDocument()
expect(learnDifyHeading.closest('section')).toHaveAttribute(
'data-step-by-step-tour-target',
STEP_BY_STEP_TOUR_TARGETS.home,
)
expect(screen.queryByRole('button', { name: 'explore.learnDify.hide' })).not.toBeInTheDocument()
})
it('should keep Learn Dify hidden when the system feature is disabled', () => {
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
renderLearnDify({ enableLearnApp: false, forceVisible: true })
expect(
screen.queryByRole('heading', { name: 'explore.learnDify.title' }),
).not.toBeInTheDocument()
})
})

View File

@ -16,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}
>
<div className="-mx-4 rounded-2xl bg-background-section p-4">
<div className="flex items-start justify-between gap-4 pb-2.5">
@ -153,7 +157,7 @@ const LearnDify = (props: LearnDifyProps) => {
if (!systemFeatures.enable_learn_app) return null
if (props.dismissible === false) return <LearnDifyContent {...props} />
if (props.dismissible === false || props.forceVisible) return <LearnDifyContent {...props} />
return <DismissibleLearnDify {...props} />
}

View File

@ -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<RequirementIconProps> = ({ iconUrl }) => {
)
}
const AppInfo: FC<Props> = ({ appId, className, categories, appDetail, onCreate }) => {
const AppInfo: FC<Props> = ({
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<Props> = ({ appId, className, categories, appDetail, onCreate
{appDetail.description}
</div>
)}
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
<span className="truncate">
{t(($) => $['tryApp.createFromSampleApp'], { ns: 'explore' })}
</span>
</Button>
{canCreate && (
<Button
variant="primary"
className="mt-3 flex w-full max-w-full"
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
onClick={onCreate}
>
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
<span className="truncate">
{t(($) => $['tryApp.createFromSampleApp'], { ns: 'explore' })}
</span>
</Button>
)}
{visibleCategories.length > 0 && (
<div className="mt-6 shrink-0">

View File

@ -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<Props> = ({ appId, app, categories, onClose, onCreate }) => {
const TryApp: FC<Props> = ({
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<Props> = ({ appId, app, categories, onClose, onCreate }) => {
className="w-[360px] shrink-0"
appDetail={appDetail}
appId={appId}
canCreate={canCreate}
categories={categories}
createButtonStepByStepTourTarget={createButtonStepByStepTourTarget}
onCreate={onCreate}
/>
</div>

View File

@ -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 (
<div className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6">
<div
className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6"
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty}
>
<div className="flex size-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-xs">
<span
aria-hidden

View File

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

View File

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

View File

@ -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', () => {

View File

@ -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(
<InstallFromMarketplace
providers={mockProviders}
searchText=""
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
/>,
)
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall,
)
const target = document.querySelector<HTMLElement>(selector)
expect(target).toHaveClass('absolute', 'inset-x-0', 'top-0', 'h-[174px]')
expect(target).toHaveAttribute('aria-hidden', 'true')
expect(target?.parentElement).toContainElement(
screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ }),
)
expect(target?.parentElement).toContainElement(screen.getByTestId('plugin-list'))
})
it('should hide bundle plugins from the list', () => {
;(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({
plugins: [

View File

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

View File

@ -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 (
<div className="rounded-[10px] bg-workflow-process-bg p-4">
<div
className="rounded-[10px] bg-workflow-process-bg p-4"
data-step-by-step-tour-target={stepByStepTourTarget}
>
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
<span aria-hidden className="i-ri-brain-2-line size-5 text-text-primary" />
</div>
@ -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' })}
</a>
),
}}
/>
@ -93,6 +105,7 @@ function EmptyProviderState({ enableMarketplace }: { enableMarketplace: boolean
}
type ProviderCardListProps = {
firstCardTarget?: string
providers: ModelProvider[]
pluginDetailMap: Map<string, PluginDetail>
notConfigured?: boolean
@ -104,7 +117,12 @@ function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<strin
)
}
function ProviderCardList({ providers, pluginDetailMap, notConfigured }: ProviderCardListProps) {
function ProviderCardList({
firstCardTarget,
providers,
pluginDetailMap,
notConfigured,
}: ProviderCardListProps) {
const sortedProviders = [...providers].sort((a, b) => {
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
@ -116,16 +134,20 @@ function ProviderCardList({ providers, pluginDetailMap, notConfigured }: Provide
return (
<div className="relative flex flex-col gap-2">
{sortedProviders.map((provider) => {
{sortedProviders.map((provider, index) => {
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
return (
<ProviderAddedCard
<div
key={provider.provider}
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
data-step-by-step-tour-target={index === 0 ? firstCardTarget : undefined}
>
<ProviderAddedCard
notConfigured={notConfigured}
provider={provider}
pluginDetail={pluginDetail}
/>
</div>
)
})}
</div>
@ -151,7 +173,9 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
return (
<div className="flex flex-col gap-2">
{IS_CLOUD_EDITION && (
<div>
<div
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderCredits}
>
<QuotaPanel providers={providers} />
</div>
)}
@ -160,9 +184,19 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
<ModelProviderListSkeleton />
</div>
)}
{showEmptyProvider && <EmptyProviderState enableMarketplace={enableMarketplace} />}
{showEmptyProvider && (
<EmptyProviderState
enableMarketplace={enableMarketplace}
stepByStepTourTarget={
!showConfiguredProviders && !showNotConfiguredProviders
? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction
: undefined
}
/>
)}
{showConfiguredProviders && (
<ProviderCardList
firstCardTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction}
providers={filteredConfiguredProviders}
pluginDetailMap={pluginDetailMap}
/>
@ -173,6 +207,11 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
{t(($) => $['modelProvider.toBeConfigured'], { ns: 'common' })}
</div>
<ProviderCardList
firstCardTarget={
!showConfiguredProviders
? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction
: undefined
}
providers={filteredNotConfiguredProviders}
notConfigured
pluginDetailMap={pluginDetailMap}
@ -185,6 +224,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
providers={providers}
searchText={searchText}
onOpenMarketplace={onOpenMarketplace}
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
/>
</div>
)}

View File

@ -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(<IntegrationsPage section="mcp" />)
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', () => {

View File

@ -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: () => <div data-testid="custom-create-card">Create Custom Tool</div>,
default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => (
<div data-testid="custom-create-card" data-step-by-step-tour-target={stepByStepTourTarget}>
Create Custom Tool
</div>
),
NewCustomToolButton: () => (
<button type="button" data-testid="toolbar-add-custom-tool">
tools.addSwaggerAPIAsTool
@ -649,8 +657,13 @@ describe('ProviderList', () => {
mockCollectionData = createDefaultCollections().filter((c) => c.type !== 'api')
renderProviderList({ category: 'api' })
expect(screen.getByTestId('custom-create-card')).toBeInTheDocument()
const customCreateCard = screen.getByTestId('custom-create-card')
expect(customCreateCard).toBeInTheDocument()
expect(screen.queryByTestId('toolbar-add-custom-tool')).not.toBeInTheDocument()
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationSwaggerToolGrid,
)
expect(document.querySelector(selector)).toBe(customCreateCard)
})
it('moves custom creation into the toolbar when API tools exist', () => {
@ -676,6 +689,18 @@ describe('ProviderList', () => {
expect(screen.getByTestId('card-my-api').parentElement).not.toHaveClass('flex-1')
})
it('anchors the Swagger API tour target to the first custom tool card', () => {
renderProviderList(undefined, 'api', 'compact')
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationSwaggerToolGrid,
)
const target = document.querySelector(selector)
expect(target).toBe(screen.getByTestId('card-my-api').parentElement)
expect(target).not.toBe(screen.getByTestId('tool-provider-grid'))
})
it('shows custom API card author and label tags from collection labels', () => {
renderProviderList(undefined, 'api', 'compact')
@ -724,6 +749,18 @@ describe('ProviderList', () => {
expect(screen.getByTestId('card-wf-tool').parentElement).toHaveClass('min-w-0')
})
it('anchors the Workflow as Tool tour target to the first workflow card', () => {
renderProviderList(undefined, 'workflow', 'compact')
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationWorkflowToolGrid,
)
const target = document.querySelector(selector)
expect(target).toBe(screen.getByTestId('card-wf-tool').parentElement)
expect(target).not.toBe(screen.getByTestId('tool-provider-grid'))
})
it('does not show the built-in badge on workflow cards', () => {
renderProviderList(undefined, 'workflow', 'compact')
@ -750,6 +787,19 @@ describe('ProviderList', () => {
expect(screen.getByTestId('workflow-empty')).toBeInTheDocument()
})
it('anchors the Workflow as Tool tour target to the bounded empty state when no workflow collections exist', () => {
mockCollectionData = createDefaultCollections().filter((c) => c.type !== 'workflow')
renderProviderList(undefined, 'workflow', 'compact')
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationWorkflowToolGrid,
)
const target = document.querySelector<HTMLElement>(selector)
expect(target).not.toBe(screen.getByTestId('tool-provider-grid'))
expect(target).toContainElement(screen.getByTestId('workflow-empty'))
})
it('does not show workflow empty state when search has no matches', () => {
renderProviderList({ category: 'workflow' })
const input = screen.getByRole('searchbox')
@ -930,6 +980,7 @@ describe('ProviderList', () => {
it('passes compact content inset to MCPList when rendered by integrations layout', () => {
renderProviderList(undefined, 'mcp', 'compact')
expect(screen.getByTestId('toolbar-add-mcp')).toBeInTheDocument()
expect(screen.getByTestId('mcp-list')).toHaveAttribute('data-content-inset', 'compact')
})
})

View File

@ -3,6 +3,7 @@ import type { IntegrationSidebarNavItemData } from '@/app/components/integration
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
export type IntegrationHeader = {
description: string
@ -23,6 +24,7 @@ export function useIntegrationNav(section: IntegrationSection) {
section: 'provider',
label: t(($) => $['settings.provider'], { ns: 'common' }),
icon: 'i-ri-brain-2-line',
stepByStepTourTarget: STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav,
}),
[t],
)
@ -32,6 +34,7 @@ export function useIntegrationNav(section: IntegrationSection) {
label: t(($) => $['settings.dataSource'], { ns: 'common' }),
icon: 'i-ri-database-2-line',
iconClassName: 'size-4',
stepByStepTourTarget: STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav,
}),
[t],
)
@ -52,6 +55,7 @@ export function useIntegrationNav(section: IntegrationSection) {
icon: 'i-custom-vender-integrations-tools',
iconClassName: 'h-[14px] w-[12.5px]',
className: 'pl-8',
stepByStepTourTarget: STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav,
},
]
@ -62,6 +66,7 @@ export function useIntegrationNav(section: IntegrationSection) {
icon: 'i-custom-vender-integrations-mcp',
iconClassName: 'h-[14.5px] w-[13.5px]',
className: 'pl-8',
stepByStepTourTarget: STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav,
},
{
section: 'workflow-tool',
@ -88,6 +93,7 @@ export function useIntegrationNav(section: IntegrationSection) {
label: t(($) => $['categorySingle.trigger'], { ns: 'plugin' }),
icon: 'i-custom-vender-integrations-trigger',
iconClassName: 'h-[13.5px] w-[13.5px]',
stepByStepTourTarget: STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav,
},
{
section: 'agent-strategy',

View File

@ -6,7 +6,7 @@ import type { DocPathWithoutLang } from '@/types/doc-paths'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import UpdateSettingDialog from '@/app/components/header/account-setting/update-setting-dialog'
import {
@ -18,6 +18,7 @@ import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import { getMarketplaceUrl } from '@/utils/var'
import { STEP_BY_STEP_TOUR_TARGETS } from '../step-by-step-tour/target-registry'
import { getPluginCategoryBySection, useIntegrationNav } from './hooks/use-integration-nav'
import { useIntegrationPermissions } from './hooks/use-integration-permissions'
import { useIntegrationSection } from './hooks/use-integration-section'
@ -127,6 +128,7 @@ export default function IntegrationsPage({
} = useIntegrationPermissions(section)
const [providerSearchText, setProviderSearchText] = useState('')
const showInstallAction = canInstallPlugin
const reserveInstallActionSlot = showInstallAction || isReferenceSettingLoading
const showUtilityActions = canDebugger || showPermissionQuickPanel
const {
activeItem,
@ -139,6 +141,13 @@ export default function IntegrationsPage({
} = useIntegrationNav(section)
const isToolSection = Boolean(toolCategoryBySection[section])
const [isToolsExpanded, setIsToolsExpanded] = useState(isToolSection)
useEffect(() => {
if (!isToolSection) return undefined
const animationFrame = window.requestAnimationFrame(() => setIsToolsExpanded(true))
return () => window.cancelAnimationFrame(animationFrame)
}, [isToolSection])
const useFillLayout =
section === 'provider' ||
section === 'data-source' ||
@ -154,7 +163,13 @@ export default function IntegrationsPage({
const pluginSettingCategory = getPluginCategoryBySection(section)
const pluginSettingAction =
showPluginCategorySetting && pluginSettingCategory ? (
<UpdateSettingDialog category={pluginSettingCategory} />
<div
data-step-by-step-tour-target={
section === 'builtin' ? STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings : undefined
}
>
<UpdateSettingDialog category={pluginSettingCategory} />
</div>
) : undefined
const marketplaceUrlPath = buildMarketplaceUrlPathByIntegrationSection(section)
const headerDescription =
@ -223,12 +238,13 @@ export default function IntegrationsPage({
'flex shrink-0 flex-col border-r border-divider-burn bg-components-panel-bg px-2 py-2 transition-[width]',
'w-50 items-end',
)}
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integration}
>
<div className="flex min-h-0 w-46 flex-1 flex-col gap-0.5 pb-4">
<div
className={cn(
'flex shrink-0 items-start pr-0 pl-2.5',
showInstallAction ? 'h-14 pt-1 pb-7' : 'mb-3 pt-1 pb-0.5',
reserveInstallActionSlot ? 'h-14 pt-1 pb-7' : 'mb-3 pt-1 pb-0.5',
)}
>
<div className="flex h-6 min-w-0 flex-1 items-center justify-center">
@ -244,7 +260,10 @@ export default function IntegrationsPage({
onSwitchToMarketplace={handleSwitchToMarketplace}
/>
)}
<nav className={cn('shrink-0 space-y-px', showInstallAction ? 'mt-6' : 'py-4')}>
{!showInstallAction && reserveInstallActionSlot && (
<div aria-hidden="true" className="h-8 w-full shrink-0" />
)}
<nav className={cn('shrink-0 space-y-px', reserveInstallActionSlot ? 'mt-6' : 'py-4')}>
<IntegrationSidebarNavItem
item={providerItem}
onSelect={onSectionChange}

View File

@ -22,6 +22,7 @@ export type IntegrationSidebarNavItemData = {
iconClassName?: string
label: string
section?: IntegrationSection
stepByStepTourTarget?: string
}
const renderIcon = (icon: IconComponent | string, className = 'size-4') => {
@ -31,6 +32,18 @@ const renderIcon = (icon: IconComponent | string, className = 'size-4') => {
return <Icon className={className} />
}
const StepByStepTourTargetAnchor = ({ target }: { target?: string }) => {
if (!target) return null
return (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-y-1 right-0 left-0"
data-step-by-step-tour-target={target}
/>
)
}
type IntegrationSidebarNavItemProps = {
item: IntegrationSidebarNavItemData
onSelect?: (section: IntegrationSection) => void
@ -47,6 +60,7 @@ export function IntegrationSidebarNavItem({
const className = cn(
integrationSidebarNavItemClassName,
item.stepByStepTourTarget && 'relative',
isActive
? integrationSidebarActiveNavItemClassName
: integrationSidebarInactiveNavItemClassName,
@ -60,10 +74,12 @@ export function IntegrationSidebarNavItem({
className={cn(
integrationSidebarNavItemClassName,
integrationSidebarDisabledNavItemClassName,
item.stepByStepTourTarget && 'relative',
item.className,
)}
aria-disabled="true"
>
<StepByStepTourTargetAnchor target={item.stepByStepTourTarget} />
<span aria-hidden className="flex size-5 shrink-0 items-center justify-center">
{renderIcon(item.icon, item.iconClassName)}
</span>
@ -94,6 +110,7 @@ export function IntegrationSidebarNavItem({
className={cn('border-none bg-transparent', className)}
onClick={() => onSelect(item.section!)}
>
<StepByStepTourTargetAnchor target={item.stepByStepTourTarget} />
{content}
</button>
)
@ -106,6 +123,7 @@ export function IntegrationSidebarNavItem({
aria-current={isActive ? 'page' : undefined}
className={className}
>
<StepByStepTourTargetAnchor target={item.stepByStepTourTarget} />
{content}
</Link>
)

View File

@ -20,7 +20,7 @@ const ToolProviderCreateAction = ({
onCustomToolCreated,
onMCPProviderCreated,
}: ToolProviderCreateActionProps) => {
if (activeTab === 'mcp' && hasCategoryCollections)
if (activeTab === 'mcp')
return (
<NewMCPButton
handleCreate={(provider: ToolWithProvider) => onMCPProviderCreated(provider.id)}

View File

@ -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) => {

View File

@ -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> = {},
): 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> = {},
): 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<StepByStepTourStateResponse> => {
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<StepByStepTourStateResponse> = {}) {
state = createState(overrides)
},
setUiState(overrides: Partial<StepByStepTourTestUiState> = {}) {
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: <T,>(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<typeof import('react-i18next')>('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<typeof import('@/service/client')>()
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<typeof createStore>; 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(
<>
<JotaiProvider store={store}>
<MainNav />
{options.extra}
</>,
</JotaiProvider>,
{ 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',

View File

@ -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);
}
}

View File

@ -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 = (
</svg>
)
const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => (
<Switch
checked={checked}
readOnly
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none"
/>
)
const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMenuProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const learnDifyHidden = useLearnDifyHiddenValue()
const setLearnDifyHidden = useSetLearnDifyHidden()
const stepByStepTourEnabled = useAtomValue(stepByStepTourEnabledForCurrentWorkspaceAtom)
const stepByStepTourStateUpdating = useAtomValue(stepByStepTourStateUpdatingAtom)
const skipRecoveryVisible = useAtomValue(stepByStepTourSkipRecoveryVisibleAtom)
const setSkipRecoveryVisible = useSetAtom(stepByStepTourSkipRecoveryVisibleAtom)
const enableStepByStepTour = useSetAtom(enableStepByStepTourForCurrentWorkspaceAtom)
const disableStepByStepTour = useSetAtom(disableStepByStepTourForCurrentWorkspaceAtom)
const setStepByStepTourShellMode = useSetStepByStepTourShellMode()
const [aboutVisible, setAboutVisible] = useState(false)
const [open, setOpen] = useState(false)
const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app
const shouldShowStepByStepTourSwitch = systemFeatures.enable_step_by_step_tour
const canToggleStepByStepTour =
Boolean(currentWorkspaceId) && !isLoadingCurrentWorkspace && !stepByStepTourStateUpdating
const handleStepByStepTourCheckedChange = (checked: boolean) => {
if (!canToggleStepByStepTour) return
setSkipRecoveryVisible(false)
const trackVisibilityToggled = () =>
trackStepByStepTourEvent({ action: checked ? 'tour_enabled' : 'tour_disabled' })
if (checked) {
setStepByStepTourShellMode('expanded')
enableStepByStepTour({
onSuccess: trackVisibilityToggled,
})
} else {
disableStepByStepTour({
onSuccess: trackVisibilityToggled,
})
}
if (checked) setOpen(false)
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) setSkipRecoveryVisible(false)
}
if (systemFeatures.branding.enabled) return null
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
aria-label={t(($) => $['mainNav.help.openMenu'], { ns: 'common' })}
data-learn-dify-help-target
@ -82,6 +146,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
'inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-components-card-border bg-components-card-bg p-0 text-text-tertiary shadow-xs transition-colors hover:bg-components-card-bg-alt hover:text-saas-dify-blue-inverted focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
triggerClassName,
open && 'bg-components-card-bg-alt text-saas-dify-blue-inverted',
skipRecoveryVisible && styles.stepByStepTourRecoveryPulse,
)}
>
{triggerIcon}
@ -131,22 +196,25 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t(($) => $['mainNav.help.learnDify'], { ns: 'common' })}
</span>
<MenuSwitchIndicator checked={!learnDifyHidden} />
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && shouldShowStepByStepTourSwitch && (
<DropdownMenuCheckboxItem
checked={stepByStepTourEnabled}
closeOnClick={false}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
disabled={!canToggleStepByStepTour}
onCheckedChange={handleStepByStepTourCheckedChange}
>
<span
aria-hidden
className={cn(
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-[5px] p-0.5 transition-colors',
!learnDifyHidden
? 'bg-components-toggle-bg'
: 'bg-components-toggle-bg-unchecked',
)}
>
<span
className={cn(
'block h-3 w-2.5 rounded-[3px] bg-components-toggle-knob shadow-sm transition-transform',
!learnDifyHidden && 'translate-x-3.5',
)}
/>
className="i-custom-vender-line-education-book-open-01 size-4 shrink-0 text-text-tertiary"
/>
<span className="min-w-0 flex-1 truncate px-1 py-0.5 system-md-regular text-text-secondary">
{t(($) => $['mainNav.help.stepByStepTour'], { ns: 'common' })}
</span>
<MenuSwitchIndicator checked={stepByStepTourEnabled} />
</DropdownMenuCheckboxItem>
)}
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}

View File

@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import StepByStepTourMount from '@/app/components/step-by-step-tour/mount'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import {
isCurrentWorkspaceDatasetOperatorAtom,
@ -127,13 +128,16 @@ export function MainNav({ className }: MainNavProps) {
</div>
)}
</div>
<div className="flex w-60 items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
</div>
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
<HelpMenu />
<div className="relative w-60 shrink-0">
<div className="flex w-60 items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
</div>
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
<HelpMenu />
</div>
</div>
<StepByStepTourMount className="absolute -top-7 left-2.5 h-8 w-[183px] overflow-visible" />
</div>
</aside>
)

View File

@ -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[]
}) => (
<div data-testid="plugin-list">
{pluginList.map((plugin) => (
<div key={plugin.plugin_id} data-testid="plugin-list-item">
{pluginList.map((plugin, index) => (
<div
key={plugin.plugin_id}
data-step-by-step-tour-target={index === 0 ? firstPluginTarget : undefined}
data-testid="plugin-list-item"
>
{plugin.plugin_id}
</div>
))}
@ -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(<PluginsPanel contentInset="compact" fixedCategory={category} />)
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([

View File

@ -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(<Empty contentInset="compact" variant="integrationsTrigger" />)
await flushEffects()
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationTriggerGrid,
)
const target = document.querySelector<HTMLElement>(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(
<Empty contentInset="compact" variant="integrationsAgentStrategy" />,
)
await flushEffects()
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationAgentStrategyEmpty,
)
const target = document.querySelector<HTMLElement>(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(<Empty contentInset="compact" variant="integrationsExtension" />)
@ -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(<Empty contentInset="compact" variant="integrationsExtension" />)
await flushEffects()
const selector = getStepByStepTourTargetSelector(
STEP_BY_STEP_TOUR_TARGETS.integrationExtensionGrid,
)
const target = document.querySelector<HTMLElement>(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) ====================

View File

@ -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}
>
<div className="flex flex-col items-center gap-y-3">
<div

View File

@ -6,6 +6,7 @@ type IPluginListProps = {
canDeletePlugin?: boolean
canUpdatePlugin?: boolean
children?: ReactNode
firstPluginTarget?: string
pluginList: PluginDetail[]
}
@ -13,18 +14,23 @@ const PluginList: FC<IPluginListProps> = ({
canDeletePlugin = true,
canUpdatePlugin = true,
children,
firstPluginTarget,
pluginList,
}) => {
return (
<div className="pb-3">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{pluginList.map((plugin) => (
<PluginItem
<div className="grid w-full grid-cols-1 gap-3 lg:grid-cols-2">
{pluginList.map((plugin, index) => (
<div
key={plugin.plugin_id}
plugin={plugin}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
/>
data-step-by-step-tour-target={index === 0 ? firstPluginTarget : undefined}
>
<PluginItem
plugin={plugin}
canDeletePlugin={canDeletePlugin}
canUpdatePlugin={canUpdatePlugin}
/>
</div>
))}
{children}
</div>

View File

@ -60,6 +60,8 @@ type PluginsPanelResultsProps = {
contentFrameClassName: string
contentInset: PluginPageContentInset
currentBuiltinToolID?: string
firstBuiltinToolTarget?: string
firstPluginTarget?: string
filteredBuiltinTools: Collection[]
filteredList: Array<PluginDetail & { latest_version: string }>
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) => (
<button
key={collection.id}
type="button"
aria-pressed={currentBuiltinToolID === collection.id}
className="min-w-0 cursor-pointer appearance-none border-0 bg-transparent p-0 text-left"
data-step-by-step-tour-target={
filteredList.length === 0 && index === 0 ? firstBuiltinToolTarget : undefined
}
onClick={() => setCurrentBuiltinToolID(collection.id)}
>
<IntegrationsToolProviderCard

View File

@ -10,6 +10,7 @@ import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isSearchResultEmpty } from '@/app/components/base/search-input/search-state'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
import ProviderDetail from '@/app/components/tools/provider/detail'
import { useGetLanguage } from '@/context/i18n'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -195,6 +196,13 @@ const PluginsPanel = ({
: isToolIntegrationPage
? t(($) => $['categorySingle.tool'], { ns: 'plugin' })
: undefined
const resultTarget = isTriggerIntegrationPage
? STEP_BY_STEP_TOUR_TARGETS.integrationTriggerGrid
: isAgentStrategyIntegrationPage
? STEP_BY_STEP_TOUR_TARGETS.integrationAgentStrategyEmpty
: isExtensionIntegrationPage
? STEP_BY_STEP_TOUR_TARGETS.integrationExtensionGrid
: undefined
const toolbar = (
<div
@ -232,6 +240,16 @@ const PluginsPanel = ({
contentFrameClassName={contentFrameClassName}
contentInset={contentInset}
currentBuiltinToolID={currentBuiltinToolID}
firstBuiltinToolTarget={
isToolIntegrationPage
? STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginFirstCard
: undefined
}
firstPluginTarget={
isToolIntegrationPage
? STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginFirstCard
: resultTarget
}
filteredBuiltinTools={filteredBuiltinTools}
filteredList={filteredList}
hasToolMarketplacePanel={hasToolMarketplacePanel}

View File

@ -0,0 +1,11 @@
# Step-by-step Tour
This directory owns the cross-route Step-by-step Tour capability.
- `state.ts` owns the server query graph, the in-memory tour session, and domain commands.
- `storage.ts` owns only the persisted shell preference.
- `mount.tsx` composes the checklist, coachmarks, navigation, and analytics.
- Route consumers resolve only the guide branch and targets that depend on their page data.
Server state remains canonical in the TanStack Query cache. Components consume narrow derived atoms
and write-only commands instead of a combined account-state facade.

View File

@ -0,0 +1,39 @@
import { trackStepByStepTourEvent } from '../analytics'
const mockTrackEvent = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: mockTrackEvent,
}))
describe('step-by-step tour analytics', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('tracks the unified event with scalar properties only', () => {
trackStepByStepTourEvent({
action: 'task_completed',
completed_task_count: 1,
home_outcome: 'lesson_app_created',
permission_variant: 'full',
task_id: 'home',
task_total: 4,
})
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,
})
})
it('omits undefined optional properties', () => {
trackStepByStepTourEvent({ action: 'tour_enabled' })
expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_enabled' })
})
})

View File

@ -0,0 +1,182 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
getStepByStepTourDropdownMenuContentProps,
useStepByStepTourControlledDropdown,
} from '../dropdown-menu'
const STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR = 'data-step-by-step-tour-highlight-part'
const getHighlightPartValue = (props: unknown): string | undefined => {
if (!props || typeof props !== 'object') return undefined
const value = (props as Record<string, unknown>)[STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR]
return typeof value === 'string' ? value : undefined
}
const noopKeyboardHandler = () => {}
function TestDropdown({
allowTriggerCloseWhileControlled,
controlledOpen,
}: {
allowTriggerCloseWhileControlled?: boolean
controlledOpen?: boolean
}) {
const menu = useStepByStepTourControlledDropdown({
allowTriggerCloseWhileControlled,
controlledOpen,
})
return (
<>
<button type="button" onClick={() => menu.onOpenChange(!menu.open)}>
Toggle menu
</button>
<button type="button" onClick={menu.close}>
Close from action
</button>
<div
aria-label="dropdown"
data-controlled={String(menu.controlled)}
data-open={String(menu.open)}
/>
</>
)
}
describe('useStepByStepTourControlledDropdown', () => {
it('keeps ordinary dropdown toggle behavior when no tour controls it', () => {
render(<TestDropdown />)
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(<TestDropdown controlledOpen />)
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(<TestDropdown controlledOpen allowTriggerCloseWhileControlled={false} />)
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(<TestDropdown controlledOpen />)
expect(screen.getByLabelText('dropdown')).toHaveAttribute('data-open', 'true')
rerender(<TestDropdown />)
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(
<div role="button" tabIndex={0} onClick={onBackgroundClick} onKeyDown={noopKeyboardHandler}>
<div
data-testid="positioner"
data-step-by-step-tour-highlight-part={getHighlightPartValue(positionerProps)}
onClickCapture={positionerProps?.onClickCapture}
onKeyDownCapture={positionerProps?.onKeyDownCapture}
onMouseDownCapture={positionerProps?.onMouseDownCapture}
onPointerDownCapture={positionerProps?.onPointerDownCapture}
>
<div
role="menu"
aria-hidden={popupProps?.['aria-hidden']}
onClickCapture={popupProps?.onClickCapture}
onKeyDownCapture={popupProps?.onKeyDownCapture}
onMouseDownCapture={popupProps?.onMouseDownCapture}
onPointerDownCapture={popupProps?.onPointerDownCapture}
>
<button type="button" role="menuitem" onClick={onAction}>
Delete
</button>
</div>
</div>
</div>,
)
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(
<div role="button" tabIndex={0} onClick={onBackgroundClick} onKeyDown={noopKeyboardHandler}>
<div data-step-by-step-tour-highlight-part={getHighlightPartValue(positionerProps)}>
<div
role="menu"
tabIndex={-1}
aria-hidden={popupProps?.['aria-hidden']}
onClick={popupProps?.onClick}
onKeyDown={noopKeyboardHandler}
>
<button type="button" role="menuitem" onClick={onAction}>
Create
</button>
</div>
</div>
</div>,
)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create' }))
expect(onAction).toHaveBeenCalledTimes(1)
expect(onPopupClick).toHaveBeenCalledTimes(1)
expect(onBackgroundClick).not.toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -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<typeof import('jotai')>('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> = {},
): 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 = <T>() => {
let reject!: (reason?: unknown) => void
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
reject = rejectPromise
resolve = resolvePromise
})
return { promise, reject, resolve }
}
describe('step-by-step tour state', () => {
let queryClient: QueryClient
let store: ReturnType<typeof createStore>
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<StepByStepTourStateResponse>(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<StepByStepTourStateResponse>()
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<StepByStepTourStateResponse>(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<StepByStepTourStateResponse>(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<StepByStepTourStateResponse>(stepByStepTourStateQueryKey)
?.completed_task_ids,
).toEqual([])
})
it('rolls back a failed projection before canonical reconciliation settles', async () => {
const reconciliation = createDeferred<StepByStepTourStateResponse>()
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<StepByStepTourStateResponse>(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<StepByStepTourStateResponse>()
const second = createDeferred<StepByStepTourStateResponse>()
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<StepByStepTourStateResponse>()
const second = createDeferred<StepByStepTourStateResponse>()
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()
})
})

View File

@ -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<string, StepByStepTourAnalyticsValue>
export const trackStepByStepTourEvent = (properties: StepByStepTourAnalyticsProperties) => {
trackEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENT, toTrackEventProperties(properties))
}

View File

@ -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<HTMLDivElement>(null)
const coachmarkSizeFrameRef = useRef<number | undefined>(undefined)
const shouldUseLatePortalRoot = guide.portalOrder === 'afterOverlays'
const portalRoot = useStepByStepTourPortalRoot(shouldUseLatePortalRoot)
const [coachmarkSize, setCoachmarkSize] =
useState<StepByStepTourCoachmarkSize>(DEFAULT_COACHMARK_SIZE)
const coachmarkPosition = useStepByStepTourCoachmarkPosition(
highlightRect,
placement,
anchorRect,
coachmarkSize,
)
const stableOverlayRef = useRef<
| {
coachmarkPosition: ReturnType<typeof useStepByStepTourCoachmarkPosition>
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) => (
<div
// eslint-disable-next-line react/no-array-index-key -- The four blocker slices are static and positional.
key={index}
aria-hidden="true"
data-step-by-step-tour-backdrop=""
data-step-by-step-tour-blocker=""
className="fixed z-50 cursor-default bg-transparent"
style={style}
/>
))
) : (
<div
aria-hidden="true"
data-step-by-step-tour-backdrop=""
className="fixed inset-0 z-50 cursor-default bg-transparent"
/>
)}
{stableOverlay && highlightStyle && (
<>
<div
aria-hidden="true"
data-step-by-step-tour-highlight=""
className="pointer-events-none fixed z-50 rounded-xl shadow-[0_0_0_9999px_var(--color-background-overlay)]"
style={highlightStyle}
/>
<div
ref={coachmarkRef}
className="fixed z-50 w-[352px] max-w-[calc(100vw-16px)]"
data-step-by-step-tour-coachmark=""
style={stableOverlay.coachmarkPosition.bubbleStyle}
>
{stableOverlay.placement === 'right' ? (
<span
aria-hidden="true"
className="pointer-events-none absolute -left-1.5 size-3 rotate-45 border-b-[0.5px] border-l-[0.5px] border-state-accent-hover-alt bg-state-accent-hover"
style={stableOverlay.coachmarkPosition.arrowStyle}
/>
) : (
<div
className={cn(
'pointer-events-none absolute h-7 w-0.5',
stableOverlay.placement === 'top' && 'rotate-180',
)}
style={getStepByStepTourVerticalArrowStyle(
stableOverlay.placement,
stableOverlay.coachmarkPosition.arrowStyle,
)}
aria-hidden="true"
>
<span className="absolute top-0 left-1/2 h-7 w-0.5 -translate-x-1/2 bg-state-accent-hover-alt shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)]" />
<span
style={VERTICAL_ARROW_DOT_STYLE}
className="absolute left-1/2 size-3 -translate-x-1/2 rounded-full border-2 border-state-accent-hover bg-state-accent-solid shadow-xs"
/>
</div>
)}
<section
aria-label={
isActionGuide ? stableOverlay.guide.description : stableOverlay.guide.title
}
className={cn(
'relative flex w-full flex-col rounded-2xl border-[0.5px] border-state-accent-hover-alt bg-state-accent-hover p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[5px]',
isActionGuide ? 'min-h-[118px]' : 'min-h-[158px]',
)}
>
{isActionGuide ? (
<>
<div className="pb-0.5 system-2xs-semibold-uppercase text-text-tertiary">
{stableOverlay.stepLabel}
</div>
<p className="mt-1 system-md-medium text-text-primary">
{stableOverlay.guide.description}
</p>
<div className="mt-auto flex h-12 items-end justify-start pt-4">
<button
type="button"
className={SKIP_BUTTON_CLASS_NAME}
onClick={stableOverlay.onSkip}
>
{stableOverlay.skipLabel}
</button>
</div>
</>
) : (
<>
<div className="pb-0.5 system-2xs-semibold-uppercase text-text-tertiary">
{stableOverlay.stepLabel}
</div>
<h2 className="mt-1 system-md-medium text-text-primary">
{stableOverlay.guide.title}
</h2>
<p className="mt-1 system-xs-regular text-text-secondary">
{stableOverlay.guide.description}
</p>
<div className="mt-auto flex h-12 items-center justify-between gap-3 pt-4">
<button
type="button"
className={SKIP_BUTTON_CLASS_NAME}
onClick={stableOverlay.onSkip}
>
{stableOverlay.skipLabel}
</button>
<div className="flex shrink-0 items-center gap-1">
{stableOverlay.guide.learnMoreHref && (
<a
href={stableOverlay.guide.learnMoreHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-8 shrink-0 items-center justify-center gap-1 rounded-lg px-3 system-sm-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{stableOverlay.guide.learnMoreLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-4" />
</a>
)}
<Button
variant="primary"
size="medium"
className="min-w-20 px-3"
onClick={stableOverlay.onComplete}
>
{stableOverlay.guide.primaryActionLabel}
</Button>
</div>
</div>
</>
)}
</section>
</div>
</>
)}
</>,
portalRoot ?? document.body,
)
}

View File

@ -0,0 +1,188 @@
import type { DropdownMenuContent } from '@langgenius/dify-ui/dropdown-menu'
import type { ComponentProps, SyntheticEvent } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useState } from 'react'
const STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR = 'data-step-by-step-tour-highlight-part'
const STEP_BY_STEP_TOUR_MENU_POPUP_NO_MOTION_CLASS_NAME =
'transition-none data-starting-style:scale-100 data-starting-style:opacity-100 data-ending-style:scale-100 data-ending-style:opacity-100'
const STEP_BY_STEP_TOUR_MENU_PRESENTATION_CLASS_NAME = 'pointer-events-none cursor-default'
type DropdownMenuContentProps = ComponentProps<typeof DropdownMenuContent>
type DropdownMenuPositionerProps = DropdownMenuContentProps['positionerProps']
type DropdownMenuPopupProps = DropdownMenuContentProps['popupProps']
type StepByStepTourDropdownMenuContentProps = {
highlightPart?: string
disableMotion?: boolean
interactionMode?: 'interactive' | 'presentation'
popupClassName?: string
popupProps?: DropdownMenuPopupProps
positionerProps?: DropdownMenuPositionerProps
}
const blockPresentationMenuEvent = (event: SyntheticEvent) => {
event.preventDefault()
event.stopPropagation()
}
const stopMenuEventPropagation = (event: SyntheticEvent) => {
event.stopPropagation()
}
const composeStepByStepTourEventHandlers = <Event extends SyntheticEvent>(
handler: ((event: Event) => void) | undefined,
stepByStepTourHandler: (event: Event) => void,
) => {
return (event: Event) => {
handler?.(event)
stepByStepTourHandler(event)
}
}
export const getStepByStepTourDropdownMenuContentProps = ({
highlightPart,
disableMotion = false,
interactionMode = 'interactive',
popupClassName,
popupProps,
positionerProps,
}: StepByStepTourDropdownMenuContentProps): Pick<
DropdownMenuContentProps,
'popupClassName' | 'popupProps' | 'positionerProps'
> => {
const isPresentation = interactionMode === 'presentation'
const nextPositionerProps =
highlightPart || isPresentation
? ({
...positionerProps,
...(highlightPart
? { [STEP_BY_STEP_TOUR_HIGHLIGHT_PART_DATA_ATTR]: highlightPart }
: undefined),
...(isPresentation
? {
onClickCapture: composeStepByStepTourEventHandlers(
positionerProps?.onClickCapture,
blockPresentationMenuEvent,
),
onMouseDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onMouseDownCapture,
blockPresentationMenuEvent,
),
onPointerDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onPointerDownCapture,
blockPresentationMenuEvent,
),
onKeyDownCapture: composeStepByStepTourEventHandlers(
positionerProps?.onKeyDownCapture,
blockPresentationMenuEvent,
),
}
: undefined),
} as DropdownMenuPositionerProps)
: positionerProps
const nextPopupProps = isPresentation
? ({
...popupProps,
'aria-hidden': true,
onClickCapture: composeStepByStepTourEventHandlers(
popupProps?.onClickCapture,
blockPresentationMenuEvent,
),
onMouseDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onMouseDownCapture,
blockPresentationMenuEvent,
),
onPointerDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onPointerDownCapture,
blockPresentationMenuEvent,
),
onKeyDownCapture: composeStepByStepTourEventHandlers(
popupProps?.onKeyDownCapture,
blockPresentationMenuEvent,
),
} as DropdownMenuPopupProps)
: ({
...popupProps,
onClick: composeStepByStepTourEventHandlers(popupProps?.onClick, stopMenuEventPropagation),
} as DropdownMenuPopupProps)
return {
popupClassName: cn(
popupClassName,
disableMotion && STEP_BY_STEP_TOUR_MENU_POPUP_NO_MOTION_CLASS_NAME,
isPresentation && STEP_BY_STEP_TOUR_MENU_PRESENTATION_CLASS_NAME,
),
popupProps: nextPopupProps,
positionerProps: nextPositionerProps,
}
}
type StepByStepTourControlledDropdownOptions = {
allowTriggerCloseWhileControlled?: boolean
controlledOpen?: boolean
}
type StepByStepTourControlledDropdownState = {
controlledOpen?: boolean
open: boolean
}
const getNextDropdownStateForControlledOpen = (
state: StepByStepTourControlledDropdownState,
controlledOpen: boolean | undefined,
): StepByStepTourControlledDropdownState => {
if (state.controlledOpen === controlledOpen) return state
return {
controlledOpen,
open:
controlledOpen === true
? true
: controlledOpen === false || state.controlledOpen === true
? false
: state.open,
}
}
export const useStepByStepTourControlledDropdown = ({
allowTriggerCloseWhileControlled = true,
controlledOpen,
}: StepByStepTourControlledDropdownOptions = {}) => {
const [state, setState] = useState<StepByStepTourControlledDropdownState>(() => ({
controlledOpen,
open: controlledOpen === true,
}))
const normalizedState = getNextDropdownStateForControlledOpen(state, controlledOpen)
if (normalizedState !== state) setState(normalizedState)
const onOpenChange = useCallback(
(nextOpen: boolean) => {
setState((currentState) => {
const nextState = getNextDropdownStateForControlledOpen(currentState, controlledOpen)
if (nextState.controlledOpen && !allowTriggerCloseWhileControlled && !nextOpen)
return nextState
return {
...nextState,
open: nextOpen,
}
})
},
[allowTriggerCloseWhileControlled, controlledOpen],
)
const close = useCallback(() => {
setState((currentState) => ({
...currentState,
open: false,
}))
}, [])
return {
close,
controlled: Boolean(normalizedState.controlledOpen && normalizedState.open),
open: normalizedState.open,
onOpenChange,
}
}

View File

@ -0,0 +1,367 @@
'use client'
import type { StepByStepTourTaskId, StepByStepTourTaskView } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useRef } from 'react'
export type FloatingChecklistProps = {
className?: string
title: string
duration: string
minimized: boolean
progress: {
ariaValueText: string
completed: number
total: number
}
completionPrompt?: {
description: string
dismissLabel: string
label: string
onDismiss: () => void
title: string
}
tasks: StepByStepTourTaskView[]
skipLabel: string
minimizeLabel: string
restoreLabel: string
getTaskCompleteLabel: (taskTitle: string) => string
getTaskIncompleteLabel: (taskTitle: string) => string
onMinimize: () => void
onRestore: () => void
onSkip: () => void
onCompleteTask: (taskId: StepByStepTourTaskId) => void
onStartTask: (taskId: StepByStepTourTaskId) => void
onUncompleteTask: (taskId: StepByStepTourTaskId) => void
}
export function FloatingChecklist({
className,
title,
duration,
minimized,
progress,
completionPrompt,
tasks,
skipLabel,
minimizeLabel,
restoreLabel,
getTaskCompleteLabel,
getTaskIncompleteLabel,
onMinimize,
onRestore,
onSkip,
onCompleteTask,
onStartTask,
onUncompleteTask,
}: FloatingChecklistProps) {
if (minimized && !completionPrompt) {
return (
<MinimizedTourPill
title={title}
progress={progress}
restoreLabel={restoreLabel}
onRestore={onRestore}
className={className}
/>
)
}
return (
<section
aria-label={title}
className={cn(
'flex max-h-[calc(100vh-16px)] w-[320px] max-w-[calc(100vw-16px)] flex-col overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur bg-clip-padding shadow-[0_8px_8px_-4px_var(--color-shadow-shadow-1),0_20px_24px_-4px_var(--color-shadow-shadow-5)] backdrop-blur-[10px]',
className,
)}
>
<div className="flex w-full shrink-0 flex-col gap-2 px-4 pt-4 pb-1">
<div className="flex w-full items-start gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="system-xl-semibold text-text-secondary">{title}</h2>
<p className="system-xs-regular text-text-tertiary">{duration}</p>
</div>
{!completionPrompt && (
<>
<Button
variant="ghost"
size="small"
className="h-6 px-1.5 text-text-tertiary"
onClick={onSkip}
>
{skipLabel}
</Button>
<Button
variant="ghost"
size="small"
className="size-6 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={minimizeLabel}
onClick={onMinimize}
>
<span aria-hidden className="i-ri-arrow-left-down-line size-4" />
</Button>
</>
)}
</div>
<TourProgress
ariaValueText={progress.ariaValueText}
completed={progress.completed}
total={progress.total}
/>
</div>
<div className="flex w-full shrink-0 flex-col gap-1 p-2">
{tasks.map((task) => (
<TourTaskRow
key={task.id}
task={task}
onCompleteTask={onCompleteTask}
getTaskCompleteLabel={getTaskCompleteLabel}
getTaskIncompleteLabel={getTaskIncompleteLabel}
onStartTask={onStartTask}
onUncompleteTask={onUncompleteTask}
/>
))}
{completionPrompt && <TourCompletionPrompt {...completionPrompt} />}
</div>
</section>
)
}
function TourCompletionPrompt({
description,
dismissLabel,
label,
onDismiss,
title,
}: NonNullable<FloatingChecklistProps['completionPrompt']>) {
const dismissRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
dismissRef.current?.focus({ preventScroll: true })
}, [])
return (
<section
aria-label={label}
aria-live="polite"
className="mt-1 flex w-full flex-col items-center rounded-xl bg-background-section px-6 py-5 text-center"
>
<span
aria-hidden
className="mb-3 flex size-5 items-center justify-center rounded-full border-2 border-saas-dify-blue-inverted text-saas-dify-blue-inverted"
>
<span className="i-ri-check-line size-3.5" />
</span>
<h3 className="system-md-semibold text-text-primary">{title}</h3>
<p className="mt-1 system-sm-regular text-text-tertiary">{description}</p>
<Button
ref={dismissRef}
variant="secondary"
size="medium"
className="mt-4 min-w-20"
onClick={onDismiss}
>
{dismissLabel}
</Button>
</section>
)
}
function MinimizedTourPill({
title,
progress,
restoreLabel,
onRestore,
className,
}: {
title: string
progress: FloatingChecklistProps['progress']
restoreLabel: string
onRestore: () => void
className?: string
}) {
return (
<button
type="button"
aria-label={restoreLabel}
className={cn(
'inline-flex h-8 w-[183px] max-w-[calc(100vw-16px)] items-center gap-2 overflow-hidden rounded-full border-[0.5px] border-components-panel-border bg-background-section px-3 py-2 text-saas-dify-blue-inverted outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
className,
)}
onClick={onRestore}
>
<span aria-hidden className="i-custom-vender-line-education-lesson-open-01 size-4 shrink-0" />
<span className="w-[104px] shrink-0 truncate system-sm-medium">{title}</span>
<span className="flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary tabular-nums">
{`${progress.completed}/${progress.total}`}
</span>
</button>
)
}
function TourProgress({
ariaValueText,
completed,
total,
}: {
ariaValueText: string
completed: number
total: number
}) {
return (
<>
<div
className="sr-only"
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={completed}
aria-valuetext={ariaValueText}
/>
<div className="flex w-full items-center gap-1 py-0.5" aria-hidden="true">
{Array.from({ length: total }, (_, index) => {
const active = index < completed
return (
<div
key={index}
className={cn(
'h-1 min-w-0 flex-1 rounded-full',
active ? 'bg-saas-dify-blue-inverted' : 'bg-components-slider-track',
)}
/>
)
})}
</div>
</>
)
}
function TourTaskRow({
task,
onCompleteTask,
getTaskCompleteLabel,
getTaskIncompleteLabel,
onStartTask,
onUncompleteTask,
}: {
task: StepByStepTourTaskView
onCompleteTask: (taskId: StepByStepTourTaskId) => void
getTaskCompleteLabel: (taskTitle: string) => string
getTaskIncompleteLabel: (taskTitle: string) => string
onStartTask: (taskId: StepByStepTourTaskId) => void
onUncompleteTask: (taskId: StepByStepTourTaskId) => void
}) {
const completed = task.status === 'completed'
const current = task.status === 'current'
const disabled = task.status === 'disabled'
const completionToggleDisabled = disabled || task.canToggleCompletion === false
const statusIndicatorDisabled = completed ? disabled : completionToggleDisabled
return (
<div
aria-current={current ? 'step' : undefined}
aria-disabled={disabled || undefined}
className={cn(
'group flex w-full gap-1 rounded-xl p-2 transition-colors',
completed ? 'items-center' : 'items-start',
current && 'bg-state-base-hover-subtle',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<div className={cn('flex min-w-0 flex-1 gap-3', completed ? 'items-center' : 'items-start')}>
<div
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-[10px] border border-components-panel-border-subtle bg-components-panel-bg text-text-accent-light-mode-only',
completed && 'opacity-50',
)}
>
<span aria-hidden className={cn('size-4', task.iconClassName)} />
</div>
<div className={cn('min-w-0 flex-1', completed && 'opacity-50')}>
<div className={cn('system-md-medium text-text-secondary', completed && 'line-through')}>
{task.title}
</div>
{!completed && (
<>
<p className="mt-0.5 system-xs-regular text-text-tertiary">
{disabled && task.disabledReason ? task.disabledReason : task.description}
</p>
<div className="mt-2 flex items-center gap-1 pb-1">
<Button
variant="secondary"
size="small"
className="min-w-[83px]"
disabled={disabled}
onClick={() => onStartTask(task.id)}
>
{task.primaryActionLabel}
</Button>
{task.learnMoreHref && task.learnMoreLabel && (
<a
href={task.learnMoreHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-6 min-w-[98px] items-center justify-center gap-1 rounded-md px-2 system-xs-medium text-text-tertiary outline-hidden hover:bg-components-button-ghost-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
{task.learnMoreLabel}
<span aria-hidden className="i-ri-arrow-right-up-line size-3.5" />
</a>
)}
</div>
</>
)}
</div>
</div>
<TaskStatusIndicator
completed={completed}
disabled={statusIndicatorDisabled}
completeLabel={getTaskCompleteLabel(task.title)}
incompleteLabel={getTaskIncompleteLabel(task.title)}
onComplete={() => onCompleteTask(task.id)}
onUncomplete={() => onUncompleteTask(task.id)}
/>
</div>
)
}
function TaskStatusIndicator({
completed,
disabled,
completeLabel,
incompleteLabel,
onComplete,
onUncomplete,
}: {
completed: boolean
disabled: boolean
completeLabel: string
incompleteLabel: string
onComplete: () => void
onUncomplete: () => void
}) {
if (completed) {
return (
<button
type="button"
aria-label={incompleteLabel}
disabled={disabled}
className="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-saas-dify-blue-accessible text-text-primary-on-surface outline-hidden hover:bg-saas-dify-blue-inverted focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-saas-dify-blue-accessible"
onClick={onUncomplete}
>
<span aria-hidden className="i-ri-check-line size-3" />
</button>
)
}
return (
<button
type="button"
aria-label={completeLabel}
disabled={disabled}
className="flex size-[18px] shrink-0 items-center justify-center rounded-full border border-components-checkbox-border bg-components-checkbox-bg-unchecked outline-hidden hover:border-state-accent-solid focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:border-components-checkbox-border"
onClick={onComplete}
/>
)
}

View File

@ -0,0 +1,805 @@
'use client'
import type { StepByStepTourGuide } from './target-registry'
import type {
StepByStepTourGuideGroup,
StepByStepTourTaskId,
StepByStepTourTaskView,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { IS_CLOUD_EDITION } from '@/config'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { currentWorkspaceAtom, isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { usePathname, useRouter } from '@/next/navigation'
import { hasPermission } from '@/utils/permission'
import { getStepByStepTourPermissionVariant, trackStepByStepTourEvent } from './analytics'
import { StepByStepTourCoachmark } from './coachmark'
import { FloatingChecklist } from './floating-widget'
import {
activeStepByStepTourGuideGroupAtom,
activeStepByStepTourGuideIndexAtom,
activeStepByStepTourGuideIndexesAtom,
activeStepByStepTourTaskIdAtom,
advanceStepByStepTourGuideAtom,
completedStepByStepTourTaskIdsAtom,
completeStepByStepTourTaskAtom,
resetStepByStepTourSessionAtom,
skipStepByStepTourAtom,
startStepByStepTourTaskAtom,
stepByStepTourEnabledForCurrentWorkspaceAtom,
stepByStepTourFirstWorkspaceIdAtom,
stepByStepTourSkippedAtom,
stepByStepTourSkipRecoveryVisibleAtom,
uncompleteStepByStepTourTaskAtom,
} from './state'
import { useSetStepByStepTourShellMode, useStepByStepTourShellModeValue } from './storage'
import {
getStepByStepTourGuideInteractionPolicy,
getStepByStepTourGuideKind,
getStepByStepTourGuides,
getStepByStepTourTargetSelector,
STEP_BY_STEP_TOUR_TARGETS,
} from './target-registry'
import { STEP_BY_STEP_TOUR_TASKS } from './tasks'
import { useStepByStepTourTarget } from './use-tour-target'
type StepByStepTourTask = (typeof STEP_BY_STEP_TOUR_TASKS)[number]
const hasCompletedAllStepByStepTourTasks = (
completedTaskIds: StepByStepTourTaskId[],
tasks: readonly StepByStepTourTask[],
) => tasks.every((task) => completedTaskIds.includes(task.id))
const isPermissionFallbackGuideGroup = (
guideGroup: StepByStepTourGuideGroup | undefined,
): guideGroup is Extract<
StepByStepTourGuideGroup,
'homeNoCreate' | 'integrationLimitedAccess' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps'
> =>
guideGroup === 'homeNoCreate' ||
guideGroup === 'integrationLimitedAccess' ||
guideGroup === 'studioNoCreateEmpty' ||
guideGroup === 'studioNoCreateWithApps'
const shouldHideOnPathname = (pathname: string) =>
pathname.startsWith('/app/') || pathname.includes('/installed/')
const isGuideEligibleForPlan = (guide: StepByStepTourGuide, canSetPluginPreferences: boolean) =>
guide.target !== STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings || canSetPluginPreferences
const isOptionalGuideTargetAvailable = (guide: StepByStepTourGuide, pathname: string) => {
if (!guide.optional) return true
if (guide.integrationSection && pathname !== buildIntegrationPath(guide.integrationSection))
return true
if (typeof document === 'undefined') return true
return Boolean(document.querySelector(getStepByStepTourTargetSelector(guide.target)))
}
const isElementVerticallyVisible = (element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
return rect.top >= 0 && rect.bottom <= viewportHeight
}
const scrollTourTargetIntoView = (element: HTMLElement) => {
if (isElementVerticallyVisible(element)) return
element.scrollIntoView({
block: 'nearest',
behavior: 'auto',
})
}
const createGuideIndexes = (guides: StepByStepTourGuide[]) => guides.map((_, index) => index)
const getActiveGuideIndexes = (
guides: StepByStepTourGuide[],
guideIndexes: number[] | undefined,
) => {
const fallbackGuideIndexes = createGuideIndexes(guides)
if (!guideIndexes?.length) return fallbackGuideIndexes
const validGuideIndexes = guideIndexes.filter((index) => index >= 0 && index < guides.length)
return validGuideIndexes.length > 0 ? validGuideIndexes : fallbackGuideIndexes
}
type StepByStepTourMountProps = {
className?: string
}
export default function StepByStepTourMount({ className }: StepByStepTourMountProps) {
const router = useRouter()
const pathname = usePathname()
const docLink = useDocLink()
const { t } = useTranslation('common')
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
const { data: systemFeatures } = useQuery(systemFeaturesQueryOptions())
const completedTaskIds = useAtomValue(completedStepByStepTourTaskIdsAtom)
const skipped = useAtomValue(stepByStepTourSkippedAtom)
const firstWorkspaceId = useAtomValue(stepByStepTourFirstWorkspaceIdAtom)
const enabledForCurrentWorkspace = useAtomValue(stepByStepTourEnabledForCurrentWorkspaceAtom)
const activeTaskId = useAtomValue(activeStepByStepTourTaskIdAtom)
const sessionGuideIndex = useAtomValue(activeStepByStepTourGuideIndexAtom)
const sessionGuideGroup = useAtomValue(activeStepByStepTourGuideGroupAtom)
const sessionGuideIndexes = useAtomValue(activeStepByStepTourGuideIndexesAtom)
const skipRecoveryVisible = useAtomValue(stepByStepTourSkipRecoveryVisibleAtom)
const setSkipRecoveryVisible = useSetAtom(stepByStepTourSkipRecoveryVisibleAtom)
const advanceGuide = useSetAtom(advanceStepByStepTourGuideAtom)
const completeTask = useSetAtom(completeStepByStepTourTaskAtom)
const resetSession = useSetAtom(resetStepByStepTourSessionAtom)
const patchSkipTour = useSetAtom(skipStepByStepTourAtom)
const startTask = useSetAtom(startStepByStepTourTaskAtom)
const uncompleteTask = useSetAtom(uncompleteStepByStepTourTaskAtom)
const shellMode = useStepByStepTourShellModeValue()
const setShellMode = useSetStepByStepTourShellMode()
const anchorRef = useRef<HTMLDivElement>(null)
const lastRequestedIntegrationRouteRef = useRef<string | undefined>(undefined)
const previousSkippedRef = useRef(skipped)
const permissionFallbackAnalyticsKeyRef = useRef<string | undefined>(undefined)
const shownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const skipTimeoutRef = useRef<number | undefined>(undefined)
const stepShownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const [checklistExiting, setChecklistExiting] = useState(false)
const currentWorkspaceId = currentWorkspace.id
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
const homeGuideGroup: Extract<StepByStepTourGuideGroup, 'homeNoCreate'> | undefined = canCreateApp
? undefined
: 'homeNoCreate'
const hasKnowledgeWalkthroughPermissions =
hasPermission(workspacePermissionKeys, 'dataset.create_and_management') &&
hasPermission(workspacePermissionKeys, 'dataset.external.connect')
const canSetPluginPreferences = hasPermission(
workspacePermissionKeys,
'plugin.plugin_preferences',
)
const integrationGuideGroup:
| Extract<StepByStepTourGuideGroup, 'integrationLimitedAccess'>
| undefined = isCurrentWorkspaceManager ? undefined : 'integrationLimitedAccess'
const hasIntegrationWalkthroughPermissions = !integrationGuideGroup
useEffect(
() => () => {
if (skipTimeoutRef.current) window.clearTimeout(skipTimeoutRef.current)
},
[],
)
const learnDifyEnabled = systemFeatures?.enable_learn_app ?? true
const stepByStepTourFeatureEnabled = Boolean(systemFeatures?.enable_step_by_step_tour)
const availableTasks = learnDifyEnabled
? STEP_BY_STEP_TOUR_TASKS
: STEP_BY_STEP_TOUR_TASKS.filter((task) => task.id !== 'home')
const availableTaskIds = availableTasks.map((task) => task.id)
const completedAvailableTaskIds = completedTaskIds.filter((taskId) =>
availableTaskIds.includes(taskId),
)
const allTasksCompleted = hasCompletedAllStepByStepTourTasks(completedTaskIds, availableTasks)
const currentTask = availableTasks.find((task) => !completedTaskIds.includes(task.id))
const activeTask = activeTaskId
? availableTasks.find((task) => task.id === activeTaskId)
: undefined
const activeGuideGroup: StepByStepTourGuideGroup | undefined =
activeTask?.id === 'home'
? homeGuideGroup
: activeTask?.id === 'integration'
? integrationGuideGroup
: sessionGuideGroup
const activeGuides = activeTask ? getStepByStepTourGuides(activeTask.id, activeGuideGroup) : []
const activeGuideIndex = sessionGuideIndex ?? 0
const activeGuide = activeGuides[activeGuideIndex]
const hasActiveGuide = Boolean(activeTask && activeGuide)
const minimized = Boolean(activeTask) || shellMode === 'collapsed'
const activeGuideIndexes =
activeGuides.length > 0
? getActiveGuideIndexes(activeGuides, sessionGuideIndexes)
.filter((index) => isGuideEligibleForPlan(activeGuides[index]!, canSetPluginPreferences))
.filter((index) => isOptionalGuideTargetAvailable(activeGuides[index]!, pathname))
: []
const activeGuidePlanIndex = activeGuideIndexes.findIndex((index) => index === activeGuideIndex)
const activeStepIndex =
activeGuideIndexes.length > 0
? activeGuidePlanIndex === -1
? activeGuideIndexes.filter((index) => index < activeGuideIndex).length
: activeGuidePlanIndex
: activeGuideIndex
const activeStepTotal = activeGuideIndexes.length || activeGuides.length
const activeGuideAnalyticsProperties =
activeTask && activeGuide
? {
task_id: activeTask.id,
guide_id: activeGuide.id,
}
: undefined
const visible =
IS_CLOUD_EDITION &&
stepByStepTourFeatureEnabled &&
enabledForCurrentWorkspace &&
(hasActiveGuide || !shouldHideOnPathname(pathname))
const overlayVisible = visible && !hasBlockingModalOpen
const completionPromptVisible = visible && allTasksCompleted && !activeTask
const checklistMinimized = completionPromptVisible ? false : minimized
const expanded = !checklistMinimized
const activeTargetElement = useStepByStepTourTarget(activeGuide?.target)
const activeGuidePlacement =
activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify
? 'top'
: activeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.integration
? 'right'
: 'bottom'
const getPermissionVariant = (taskId: StepByStepTourTaskId) =>
getStepByStepTourPermissionVariant({
canCreateApp,
hasIntegrationWalkthroughPermissions,
hasKnowledgeWalkthroughPermissions,
taskId,
})
const trackTaskCompleted = (
completedTaskIds: StepByStepTourTaskId[],
taskId: StepByStepTourTaskId,
) => {
const completedAvailableTaskIds = completedTaskIds.filter((completedTaskId) =>
availableTaskIds.includes(completedTaskId),
)
trackStepByStepTourEvent({
action: 'task_completed',
task_id: taskId,
completed_task_count: completedAvailableTaskIds.length,
permission_variant: getPermissionVariant(taskId),
task_total: availableTasks.length,
})
if (hasCompletedAllStepByStepTourTasks(completedTaskIds, availableTasks)) {
trackStepByStepTourEvent({
action: 'tour_completed',
completed_task_count: completedAvailableTaskIds.length,
task_total: availableTasks.length,
})
}
}
const trackTourSkipped = (completedTaskIds: StepByStepTourTaskId[]) => {
trackStepByStepTourEvent({
action: 'tour_skipped',
task_id: activeTask?.id,
completed_task_count: completedTaskIds.filter((taskId) => availableTaskIds.includes(taskId))
.length,
})
}
useEffect(() => {
if (activeTask?.id !== 'integration' || !activeGuide?.integrationSection) return
const activeGuideRoute = buildIntegrationPath(activeGuide.integrationSection)
if (pathname === activeGuideRoute) {
lastRequestedIntegrationRouteRef.current = undefined
return
}
if (lastRequestedIntegrationRouteRef.current === activeGuideRoute) return
lastRequestedIntegrationRouteRef.current = activeGuideRoute
router.push(activeGuideRoute)
}, [activeGuide?.integrationSection, activeTask?.id, pathname, router])
useEffect(() => {
if (!activeTargetElement) return
scrollTourTargetIntoView(activeTargetElement)
}, [activeGuide?.target, activeTargetElement])
useEffect(() => {
if (!visible) return
const entryPoint = previousSkippedRef.current
? 'reenabled_after_skip'
: firstWorkspaceId === currentWorkspaceId
? 'first_workspace'
: 'help_menu_enabled'
const shownAnalyticsKey = `${currentWorkspaceId}:${entryPoint}`
if (shownAnalyticsKeyRef.current === shownAnalyticsKey) return
shownAnalyticsKeyRef.current = shownAnalyticsKey
trackStepByStepTourEvent({
action: 'tour_shown',
completed_task_count: completedAvailableTaskIds.length,
entry_point: entryPoint,
task_total: availableTasks.length,
})
}, [
availableTasks.length,
completedAvailableTaskIds.length,
currentWorkspaceId,
firstWorkspaceId,
visible,
])
useEffect(() => {
if (!visible || !activeTask || !activeGuide || !activeTargetElement) return
const guideAnalyticsProperties = { task_id: activeTask.id, guide_id: activeGuide.id }
const stepShownAnalyticsKey = [
currentWorkspaceId,
guideAnalyticsProperties.task_id,
guideAnalyticsProperties.guide_id,
].join(':')
if (stepShownAnalyticsKeyRef.current === stepShownAnalyticsKey) return
stepShownAnalyticsKeyRef.current = stepShownAnalyticsKey
trackStepByStepTourEvent({
action: 'guide_shown',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}, [
activeGuide,
activeGuideGroup,
activeStepIndex,
activeStepTotal,
activeTargetElement,
activeTask,
currentWorkspaceId,
visible,
])
useEffect(() => {
if (!visible) return
if (currentTask?.id === 'knowledge' && !hasKnowledgeWalkthroughPermissions) {
const fallbackAnalyticsKey = `${currentWorkspaceId}:knowledge:no_knowledge_permission`
if (permissionFallbackAnalyticsKeyRef.current === fallbackAnalyticsKey) return
permissionFallbackAnalyticsKeyRef.current = fallbackAnalyticsKey
trackStepByStepTourEvent({
action: 'permission_fallback_shown',
task_id: 'knowledge',
permission_variant: 'no_knowledge_permission',
})
return
}
if (!activeTask) return
if (!isPermissionFallbackGuideGroup(activeGuideGroup)) return
const fallbackAnalyticsKey = `${currentWorkspaceId}:${activeTask.id}:${activeGuideGroup}`
if (permissionFallbackAnalyticsKeyRef.current === fallbackAnalyticsKey) return
permissionFallbackAnalyticsKeyRef.current = fallbackAnalyticsKey
trackStepByStepTourEvent({
action: 'permission_fallback_shown',
task_id: activeTask.id,
permission_variant:
activeTask.id === 'integration' ? 'no_integration_permission' : 'no_create',
})
}, [
activeGuideGroup,
activeTask,
currentTask?.id,
currentWorkspaceId,
hasKnowledgeWalkthroughPermissions,
visible,
])
useEffect(() => {
previousSkippedRef.current = skipped
}, [skipped])
if (!visible && !skipRecoveryVisible) return null
const title = t(($) => $['stepByStepTour.title'])
const taskCopy: Record<
StepByStepTourTaskId,
Pick<StepByStepTourTaskView, 'title' | 'description' | 'primaryActionLabel'>
> = {
home: {
title: canCreateApp
? t(($) => $['stepByStepTour.tasks.home.title'])
: t(($) => $['stepByStepTour.tasks.home.noCreate.title']),
description: canCreateApp
? t(($) => $['stepByStepTour.tasks.home.description'])
: t(($) => $['stepByStepTour.tasks.home.noCreate.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.home.primaryActionLabel']),
},
studio: {
title: canCreateApp
? t(($) => $['stepByStepTour.tasks.studio.title'])
: t(($) => $['stepByStepTour.tasks.studio.noCreate.title']),
description: canCreateApp
? t(($) => $['stepByStepTour.tasks.studio.description'])
: t(($) => $['stepByStepTour.tasks.studio.noCreate.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.studio.primaryActionLabel']),
},
knowledge: {
title: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.title'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.title']),
description: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.description'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.description']),
primaryActionLabel: hasKnowledgeWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.knowledge.primaryActionLabel'])
: t(($) => $['stepByStepTour.tasks.knowledge.noPermission.primaryActionLabel']),
},
integration: {
title: hasIntegrationWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.integration.title'])
: t(($) => $['stepByStepTour.tasks.integration.noPermission.title']),
description: hasIntegrationWalkthroughPermissions
? t(($) => $['stepByStepTour.tasks.integration.description'])
: t(($) => $['stepByStepTour.tasks.integration.noPermission.description']),
primaryActionLabel: t(($) => $['stepByStepTour.tasks.integration.primaryActionLabel']),
},
}
const tasks = availableTasks.map((task): StepByStepTourTaskView => {
const completed = completedTaskIds.includes(task.id)
const knowledgeUnavailable = task.id === 'knowledge' && !hasKnowledgeWalkthroughPermissions
return {
...taskCopy[task.id],
id: task.id,
iconClassName: knowledgeUnavailable ? 'i-ri-lock-line' : task.iconClassName,
status: completed ? 'completed' : task.id === currentTask?.id ? 'current' : 'pending',
canToggleCompletion: false,
}
})
const skipTour = () => {
if (checklistExiting) return
setChecklistExiting(true)
skipTimeoutRef.current = window.setTimeout(() => {
patchSkipTour({
onSuccess: trackTourSkipped,
onError: () => {
setChecklistExiting(false)
setSkipRecoveryVisible(false)
},
})
setChecklistExiting(false)
setSkipRecoveryVisible(true)
}, 160)
}
const skipActiveGuide = () => {
const guideAnalyticsProperties = activeGuideAnalyticsProperties
if (guideAnalyticsProperties) {
trackStepByStepTourEvent({
action: 'guide_skipped',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}
resetSession()
setShellMode('expanded')
}
const getNextVisibleActiveGuideIndex = (startIndex: number) => {
if (activeGuideIndexes.length > 0) {
let nextGuideIndexes = activeGuideIndexes
let nextGuideIndex = nextGuideIndexes.find((index) => index >= startIndex)
while (nextGuideIndex !== undefined) {
if (
isGuideEligibleForPlan(activeGuides[nextGuideIndex]!, canSetPluginPreferences) &&
isOptionalGuideTargetAvailable(activeGuides[nextGuideIndex]!, pathname)
) {
return { activeGuideIndex: nextGuideIndex, activeGuideIndexes: nextGuideIndexes }
}
nextGuideIndexes = nextGuideIndexes.filter((index) => index !== nextGuideIndex)
nextGuideIndex = nextGuideIndexes.find((index) => index >= startIndex)
}
return { activeGuideIndex: -1, activeGuideIndexes: nextGuideIndexes }
}
for (let index = startIndex; index < activeGuides.length; index += 1) {
if (
isGuideEligibleForPlan(activeGuides[index]!, canSetPluginPreferences) &&
isOptionalGuideTargetAvailable(activeGuides[index]!, pathname)
) {
return { activeGuideIndex: index, activeGuideIndexes: undefined }
}
}
return { activeGuideIndex: -1, activeGuideIndexes: undefined }
}
const completeActiveGuide = () => {
if (!activeTask || !activeGuide) return
if (activeGuide.completionMode === 'external') return
const guideAnalyticsProperties = activeGuideAnalyticsProperties
if (guideAnalyticsProperties) {
trackStepByStepTourEvent({
action: 'guide_completed',
task_id: guideAnalyticsProperties.task_id,
guide_id: guideAnalyticsProperties.guide_id,
})
}
if (activeGuideIndex < activeGuides.length - 1) {
const nextActiveGuide = getNextVisibleActiveGuideIndex(activeGuideIndex + 1)
if (nextActiveGuide.activeGuideIndex === -1) {
completeTask({
taskId: activeTask.id,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, activeTask.id),
})
resetSession()
setShellMode('expanded')
return
}
advanceGuide({
guideIndex: nextActiveGuide.activeGuideIndex,
guideIndexes: nextActiveGuide.activeGuideIndexes,
})
return
}
completeTask({
taskId: activeTask.id,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, activeTask.id),
})
resetSession()
setShellMode('expanded')
}
const dismissCompletedTour = () => {
patchSkipTour({
onSuccess: trackTourSkipped,
})
resetSession()
setShellMode('expanded')
}
const floatingChecklist = (
<FloatingChecklist
title={title}
duration={t(($) => $['stepByStepTour.duration'])}
minimized={checklistMinimized}
progress={{
ariaValueText: t(($) => $['stepByStepTour.progressAriaValueText'], {
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}),
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}}
completionPrompt={
completionPromptVisible
? {
label: t(($) => $['stepByStepTour.completion.label']),
title: t(($) => $['stepByStepTour.completion.title']),
description: t(($) => $['stepByStepTour.completion.description']),
dismissLabel: t(($) => $['stepByStepTour.completion.dismiss']),
onDismiss: dismissCompletedTour,
}
: undefined
}
tasks={tasks}
skipLabel={t(($) => $['stepByStepTour.skip'])}
minimizeLabel={t(($) => $['stepByStepTour.minimize'])}
restoreLabel={t(($) => $['stepByStepTour.restore'])}
getTaskCompleteLabel={(taskTitle) =>
t(($) => $['stepByStepTour.markTaskComplete'], { title: taskTitle })
}
getTaskIncompleteLabel={(taskTitle) =>
t(($) => $['stepByStepTour.markTaskIncomplete'], { title: taskTitle })
}
onMinimize={() => {
setShellMode('collapsed')
}}
onRestore={() => {
setShellMode('expanded')
}}
onSkip={skipTour}
onCompleteTask={(taskId) => {
const guides = getStepByStepTourGuides(taskId, sessionGuideGroup)
const hasExternalCompletionGuide = guides.some(
(guide) => getStepByStepTourGuideKind(guide) === 'action',
)
if (hasExternalCompletionGuide) return
completeTask({
taskId,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, taskId),
})
}}
onStartTask={(taskId) => {
const task = availableTasks.find((item) => item.id === taskId)
if (!task) return
const guideGroup =
taskId === 'home'
? homeGuideGroup
: taskId === 'integration'
? integrationGuideGroup
: undefined
trackStepByStepTourEvent({
action: 'task_started',
task_id: taskId,
permission_variant: getPermissionVariant(taskId),
})
if (taskId === 'knowledge' && !hasKnowledgeWalkthroughPermissions) {
completeTask({
taskId,
onSuccess: (completedTaskIds) => trackTaskCompleted(completedTaskIds, taskId),
})
resetSession()
setShellMode('expanded')
return
}
const guides = getStepByStepTourGuides(taskId, guideGroup)
const guideIndexes = createGuideIndexes(guides).filter((index) =>
isGuideEligibleForPlan(guides[index]!, canSetPluginPreferences),
)
startTask({
taskId,
guideGroup,
guideIndexes: guideIndexes.length > 0 ? guideIndexes : undefined,
})
router.push(task.route)
}}
onUncompleteTask={(taskId) => {
uncompleteTask({
taskId,
onSuccess: (completedTaskIds) => {
trackStepByStepTourEvent({
action: 'task_reopened',
task_id: taskId,
completed_task_count: completedTaskIds.filter((completedTaskId) =>
availableTaskIds.includes(completedTaskId),
).length,
})
},
})
}}
className={cn(
'transition-opacity duration-150 ease-out motion-reduce:transition-none',
checklistExiting && 'opacity-0',
)}
/>
)
return (
<div className={cn('relative', className)}>
{overlayVisible && !allTasksCompleted && activeTask && activeGuide && activeTargetElement && (
<StepByStepTourCoachmark
guide={{
...activeGuide,
description: t(($) => $[activeGuide.description]),
learnMoreHref: activeGuide.learnMoreDocPath
? docLink(activeGuide.learnMoreDocPath)
: undefined,
learnMoreLabel: t(($) => $[activeGuide.learnMoreLabel]),
primaryActionLabel: t(($) => $[activeGuide.primaryActionLabel]),
title: t(($) => $[activeGuide.title]),
}}
targetElement={activeTargetElement}
placement={activeGuidePlacement}
stepLabel={t(($) => $['stepByStepTour.stepLabel'], {
current: activeStepIndex + 1,
total: activeStepTotal,
})}
skipLabel={t(($) => $['stepByStepTour.skip'])}
interactionPolicy={getStepByStepTourGuideInteractionPolicy(
activeGuide,
activeTask.canClickThrough,
)}
onSkip={skipActiveGuide}
onComplete={completeActiveGuide}
/>
)}
{visible && (!allTasksCompleted || completionPromptVisible) && (
<Popover open={overlayVisible && expanded}>
<div
ref={anchorRef}
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-0 w-full"
/>
{checklistMinimized && floatingChecklist}
{overlayVisible && (
<PopoverContent
placement="top-start"
sideOffset={0}
positionerProps={{
anchor: anchorRef,
collisionPadding: 8,
collisionAvoidance: {
side: 'shift',
align: 'shift',
fallbackAxisSide: 'none',
},
}}
popupClassName="overflow-visible rounded-none border-0 bg-transparent p-0 shadow-none"
>
{floatingChecklist}
</PopoverContent>
)}
</Popover>
)}
{skipRecoveryVisible && (
<SkipRecoveryPrompt
label={t(($) => $['stepByStepTour.skipRecovery.label'])}
message={t(($) => $['stepByStepTour.skipRecovery.message'])}
dismissLabel={t(($) => $['stepByStepTour.skipRecovery.dismiss'])}
onDismiss={() => setSkipRecoveryVisible(false)}
/>
)}
</div>
)
}
function SkipRecoveryPrompt({
dismissLabel,
label,
message,
onDismiss,
}: {
dismissLabel: string
label: string
message: string
onDismiss: () => void
}) {
const dismissRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
dismissRef.current?.focus({ preventScroll: true })
}, [])
return (
<section
aria-label={label}
className="fixed bottom-[76px] left-1.5 z-50 flex w-[260px] max-w-[calc(100vw-12px)] flex-col gap-1 rounded-2xl border-[0.5px] border-state-accent-hover-alt bg-state-accent-hover p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[10px]"
>
<p className="system-sm-regular text-text-secondary">{message}</p>
<div className="flex h-12 items-end justify-end pt-4">
<Button
ref={dismissRef}
variant="primary"
size="medium"
className="w-20"
onClick={onDismiss}
>
{dismissLabel}
</Button>
</div>
<span
aria-hidden
className="absolute top-full left-[214px] h-7 w-0.5 bg-state-accent-hover-alt"
/>
<span
aria-hidden
className="absolute top-[calc(100%+22px)] left-[209px] size-3 rounded-full border-2 border-state-accent-hover bg-state-accent-solid shadow-xs"
/>
</section>
)
}

View File

@ -0,0 +1,391 @@
'use client'
import type {
StepByStepTourStatePatchPayload,
StepByStepTourStateResponse,
} from '@dify/contracts/api/console/onboarding/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { Getter, Setter } from 'jotai'
import type {
StepByStepTourGuideGroup,
StepByStepTourSessionState,
StepByStepTourTaskId,
} from './types'
import { atom } from 'jotai'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { IS_CLOUD_EDITION } from '@/config'
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
import { consoleQuery } from '@/service/client'
const stepByStepTourStateQueryKey = () =>
consoleQuery.onboarding.stepByStepTour.state.get.queryKey()
const stepByStepTourStateMutationScope = {
id: 'step-by-step-tour-state',
}
const stepByStepTourStateQueryAtom = atomWithQuery(() =>
consoleQuery.onboarding.stepByStepTour.state.get.queryOptions({
enabled: IS_CLOUD_EDITION,
}),
)
const canonicalStepByStepTourStateAtom = selectAtom(
stepByStepTourStateQueryAtom,
(query) => query.data,
)
type PendingStepByStepTourStateCommand = {
body: StepByStepTourStatePatchPayload
id: symbol
workspaceId?: string
}
const pendingStepByStepTourStateCommandsAtom = atom<PendingStepByStepTourStateCommand[]>([])
const stepByStepTourStateReconciliationRevisionAtom = atom(0)
const addId = <T extends string>(values: T[] | undefined, value: T | null | undefined): T[] => {
const nextValues = values ?? []
if (!value || nextValues.includes(value)) return nextValues
return [...nextValues, value]
}
const removeId = <T extends string>(values: T[] | undefined, value: T | null | undefined): T[] => {
if (!value) return values ?? []
return (values ?? []).filter((item) => item !== value)
}
const applyStepByStepTourStateCommand = (
state: StepByStepTourStateResponse | undefined,
command: PendingStepByStepTourStateCommand,
): StepByStepTourStateResponse => {
const currentState = state ?? {}
const { body, workspaceId } = command
switch (body.action) {
case 'complete_task':
return {
...currentState,
completed_task_ids: addId(currentState.completed_task_ids, body.task_id),
}
case 'uncomplete_task':
return {
...currentState,
completed_task_ids: removeId(currentState.completed_task_ids, body.task_id),
}
case 'skip':
return {
...currentState,
skipped: true,
manually_enabled_workspace_ids: removeId(
currentState.manually_enabled_workspace_ids,
workspaceId,
),
}
case 'enable_current_workspace':
return {
...currentState,
skipped: false,
manually_enabled_workspace_ids: addId(
currentState.manually_enabled_workspace_ids,
workspaceId,
),
manually_disabled_workspace_ids: removeId(
currentState.manually_disabled_workspace_ids,
workspaceId,
),
}
case 'disable_current_workspace':
return {
...currentState,
manually_enabled_workspace_ids: removeId(
currentState.manually_enabled_workspace_ids,
workspaceId,
),
manually_disabled_workspace_ids: addId(
currentState.manually_disabled_workspace_ids,
workspaceId,
),
}
}
}
const stepByStepTourStateDataAtom = atom((get) =>
get(pendingStepByStepTourStateCommandsAtom).reduce(
applyStepByStepTourStateCommand,
get(canonicalStepByStepTourStateAtom),
),
)
export const completedStepByStepTourTaskIdsAtom = atom<StepByStepTourTaskId[]>(
(get) =>
get(stepByStepTourStateDataAtom)?.completed_task_ids?.filter(
(taskId): taskId is StepByStepTourTaskId => Boolean(taskId),
) ?? [],
)
export const stepByStepTourSkippedAtom = atom((get) =>
Boolean(get(stepByStepTourStateDataAtom)?.skipped),
)
export const stepByStepTourFirstWorkspaceIdAtom = atom(
(get) => get(stepByStepTourStateDataAtom)?.first_workspace_id ?? undefined,
)
export const stepByStepTourEnabledForCurrentWorkspaceAtom = atom((get) => {
const state = get(stepByStepTourStateDataAtom)
const currentWorkspaceId = get(currentWorkspaceIdAtom)
if (!currentWorkspaceId || state?.skipped) return false
const manuallyDisabledWorkspaceIds = state?.manually_disabled_workspace_ids ?? []
const manuallyEnabledWorkspaceIds = state?.manually_enabled_workspace_ids ?? []
return (
!manuallyDisabledWorkspaceIds.includes(currentWorkspaceId) &&
(state?.first_workspace_id === currentWorkspaceId ||
manuallyEnabledWorkspaceIds.includes(currentWorkspaceId))
)
})
const initialStepByStepTourSessionState: StepByStepTourSessionState = {
activeTaskId: undefined,
activeGuideIndex: undefined,
activeGuideGroup: undefined,
activeGuideIndexes: undefined,
}
export const stepByStepTourSessionAtom = atom<StepByStepTourSessionState>(
initialStepByStepTourSessionState,
)
export const activeStepByStepTourTaskIdAtom = atom(
(get) => get(stepByStepTourSessionAtom).activeTaskId,
)
export const activeStepByStepTourGuideIndexAtom = atom(
(get) => get(stepByStepTourSessionAtom).activeGuideIndex,
)
export const activeStepByStepTourGuideGroupAtom = atom(
(get) => get(stepByStepTourSessionAtom).activeGuideGroup,
)
export const activeStepByStepTourGuideIndexesAtom = atom(
(get) => get(stepByStepTourSessionAtom).activeGuideIndexes,
)
export const stepByStepTourSkipRecoveryVisibleAtom = atom(false)
export const startStepByStepTourTaskAtom = atom(
null,
(
_get,
set,
{
taskId,
guideGroup,
guideIndexes,
}: {
taskId: StepByStepTourTaskId
guideGroup?: StepByStepTourGuideGroup
guideIndexes?: number[]
},
) => {
set(stepByStepTourSessionAtom, {
activeTaskId: taskId,
activeGuideIndex: 0,
activeGuideGroup: guideGroup,
activeGuideIndexes: guideIndexes,
})
},
)
export const advanceStepByStepTourGuideAtom = atom(
null,
(
get,
set,
{
guideIndex,
guideIndexes,
}: {
guideIndex: number
guideIndexes?: number[]
},
) => {
set(stepByStepTourSessionAtom, {
...get(stepByStepTourSessionAtom),
activeGuideIndex: guideIndex,
activeGuideIndexes: guideIndexes,
})
},
)
export const resolveStepByStepTourGuideGroupAtom = atom(
null,
(
get,
set,
{
taskId,
guideGroup,
}: {
taskId: StepByStepTourTaskId
guideGroup: StepByStepTourGuideGroup
},
) => {
const session = get(stepByStepTourSessionAtom)
if (session.activeTaskId !== taskId || session.activeGuideGroup === guideGroup) return
set(stepByStepTourSessionAtom, {
...session,
activeGuideGroup: guideGroup,
activeGuideIndex: 0,
activeGuideIndexes: undefined,
})
},
)
export const resetStepByStepTourSessionAtom = atom(null, (_get, set) => {
set(stepByStepTourSessionAtom, initialStepByStepTourSessionState)
})
type StepByStepTourStateCommandOptions = {
onSuccess?: (completedTaskIds: StepByStepTourTaskId[]) => void
onError?: () => void
}
const settleStepByStepTourStateCommand = async (
get: Getter,
set: Setter,
queryClient: QueryClient,
command: PendingStepByStepTourStateCommand,
) => {
set(pendingStepByStepTourStateCommandsAtom, (commands) =>
commands.filter(({ id }) => id !== command.id),
)
const reconciliationRevision = get(stepByStepTourStateReconciliationRevisionAtom)
const hasPendingCommands = get(pendingStepByStepTourStateCommandsAtom).length > 0
if (reconciliationRevision > 0 && !hasPendingCommands) {
await queryClient
.invalidateQueries({
exact: true,
queryKey: stepByStepTourStateQueryKey(),
})
.catch(() => undefined)
const reconciliationIsCurrent =
get(stepByStepTourStateReconciliationRevisionAtom) === reconciliationRevision
if (get(pendingStepByStepTourStateCommandsAtom).length === 0 && reconciliationIsCurrent)
set(stepByStepTourStateReconciliationRevisionAtom, 0)
}
}
const patchStepByStepTourStateMutationAtom = atomWithMutation((get) => {
const queryClient = get(queryClientAtom)
return consoleQuery.onboarding.stepByStepTour.state.patch.mutationOptions({
scope: stepByStepTourStateMutationScope,
onMutate: () => {
return queryClient.cancelQueries({
exact: true,
queryKey: stepByStepTourStateQueryKey(),
})
},
})
})
export const stepByStepTourStateUpdatingAtom = atom(
(get) => get(pendingStepByStepTourStateCommandsAtom).length > 0,
)
const patchStepByStepTourState = async (
get: Getter,
set: Setter,
body: StepByStepTourStatePatchPayload,
options?: StepByStepTourStateCommandOptions,
) => {
if (!IS_CLOUD_EDITION) return
const command: PendingStepByStepTourStateCommand = {
body,
id: Symbol('step-by-step-tour-state-command'),
workspaceId: get(currentWorkspaceIdAtom) || undefined,
}
const mutation = get(patchStepByStepTourStateMutationAtom)
const queryClient = get(queryClientAtom)
set(pendingStepByStepTourStateCommandsAtom, (commands) => [...commands, command])
let state: Awaited<ReturnType<typeof mutation.mutateAsync>>
try {
state = await mutation.mutateAsync({ body })
} catch {
set(stepByStepTourStateReconciliationRevisionAtom, (revision) => revision + 1)
const reconciliation = settleStepByStepTourStateCommand(get, set, queryClient, command)
options?.onError?.()
await reconciliation
return
}
queryClient.setQueryData(stepByStepTourStateQueryKey(), state)
const reconciliation = settleStepByStepTourStateCommand(get, set, queryClient, command)
options?.onSuccess?.(
state.completed_task_ids?.filter((taskId): taskId is StepByStepTourTaskId => Boolean(taskId)) ??
[],
)
await reconciliation
}
export const skipStepByStepTourAtom = atom(
null,
(get, set, options?: StepByStepTourStateCommandOptions) => {
return patchStepByStepTourState(get, set, { action: 'skip' }, options)
},
)
export const completeStepByStepTourTaskAtom = atom(
null,
(
get,
set,
{ taskId, ...options }: StepByStepTourStateCommandOptions & { taskId: StepByStepTourTaskId },
) => {
return patchStepByStepTourState(get, set, { action: 'complete_task', task_id: taskId }, options)
},
)
export const uncompleteStepByStepTourTaskAtom = atom(
null,
(
get,
set,
{ taskId, ...options }: StepByStepTourStateCommandOptions & { taskId: StepByStepTourTaskId },
) => {
return patchStepByStepTourState(
get,
set,
{ action: 'uncomplete_task', task_id: taskId },
options,
)
},
)
export const enableStepByStepTourForCurrentWorkspaceAtom = atom(
null,
(get, set, options?: StepByStepTourStateCommandOptions) => {
return patchStepByStepTourState(get, set, { action: 'enable_current_workspace' }, options)
},
)
export const disableStepByStepTourForCurrentWorkspaceAtom = atom(
null,
(get, set, options?: StepByStepTourStateCommandOptions) => {
return patchStepByStepTourState(get, set, { action: 'disable_current_workspace' }, options)
},
)

View File

@ -0,0 +1,19 @@
'use client'
import { createLocalStorageState } from 'foxact/create-local-storage-state'
type StepByStepTourShellMode = 'expanded' | 'collapsed'
export const STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY = 'step-by-step-tour-shell-mode'
const [
_useStepByStepTourShellMode,
useStepByStepTourShellModeValue,
useSetStepByStepTourShellMode,
] = createLocalStorageState<StepByStepTourShellMode>(
STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY,
'expanded',
{ raw: true },
)
export { useSetStepByStepTourShellMode, useStepByStepTourShellModeValue }

View File

@ -0,0 +1,480 @@
import type { StepByStepTourGuideGroup, StepByStepTourTaskId } from './types'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import type { I18nKeysWithPrefix } from '@/types/i18n'
export const STEP_BY_STEP_TOUR_TARGETS = {
home: 'step-by-step-tour-home',
homeTryAppCreate: 'step-by-step-tour-home-try-app-create',
studio: 'step-by-step-tour-studio',
studioEmptyTemplate: 'step-by-step-tour-studio-empty-template',
studioEmptyBlank: 'step-by-step-tour-studio-empty-blank',
studioEmptyDSL: 'step-by-step-tour-studio-empty-dsl',
studioEmptyLearnDify: 'step-by-step-tour-studio-empty-learn-dify',
studioWithAppsCreate: 'step-by-step-tour-studio-with-apps-create',
studioWithAppsCreateMenu: 'step-by-step-tour-studio-with-apps-create-menu',
studioWithAppsFirstAppCard: 'step-by-step-tour-studio-with-apps-first-app-card',
studioWithAppsFirstAppCardActionsMenu:
'step-by-step-tour-studio-with-apps-first-app-card-actions-menu',
studioNoCreateEmpty: 'step-by-step-tour-studio-no-create-empty',
studioNoCreateFirstAppCard: 'step-by-step-tour-studio-no-create-first-app-card',
studioNoCreateFirstAppRowCard: 'step-by-step-tour-studio-no-create-first-app-row-card',
knowledge: 'step-by-step-tour-knowledge',
knowledgeEmptyCreate: 'step-by-step-tour-knowledge-empty-create',
knowledgeEmptyPipeline: 'step-by-step-tour-knowledge-empty-pipeline',
knowledgeEmptyConnect: 'step-by-step-tour-knowledge-empty-connect',
knowledgeWithDatasetsCreate: 'step-by-step-tour-knowledge-with-datasets-create',
knowledgeWithDatasetsCreateMenu: 'step-by-step-tour-knowledge-with-datasets-create-menu',
knowledgeWithDatasetsFirstCard: 'step-by-step-tour-knowledge-with-datasets-first-card',
knowledgeWithDatasetsFirstCardActionsMenu:
'step-by-step-tour-knowledge-with-datasets-first-card-actions-menu',
integration: 'step-by-step-tour-integration',
integrationModelProviderNav: 'step-by-step-tour-integration-model-provider-nav',
integrationToolPluginNav: 'step-by-step-tour-integration-tool-plugin-nav',
integrationMcpNav: 'step-by-step-tour-integration-mcp-nav',
integrationDataSourceNav: 'step-by-step-tour-integration-data-source-nav',
integrationTriggerNav: 'step-by-step-tour-integration-trigger-nav',
integrationUpdateSettings: 'step-by-step-tour-integration-update-settings',
integrationModelProviderCredits: 'step-by-step-tour-integration-model-provider-credits',
integrationModelProviderProduction: 'step-by-step-tour-integration-model-provider-production',
integrationModelProviderInstall: 'step-by-step-tour-integration-model-provider-install',
integrationToolPluginAutoUpdate: 'step-by-step-tour-integration-tool-plugin-auto-update',
integrationToolPluginFirstCard: 'step-by-step-tour-integration-tool-plugin-first-card',
integrationMcpAdd: 'step-by-step-tour-integration-mcp-add',
integrationMcpFirstCard: 'step-by-step-tour-integration-mcp-first-card',
integrationWorkflowToolGrid: 'step-by-step-tour-integration-workflow-tool-grid',
integrationSwaggerToolGrid: 'step-by-step-tour-integration-swagger-tool-grid',
integrationDataSourceFirstCard: 'step-by-step-tour-integration-data-source-first-card',
integrationTriggerGrid: 'step-by-step-tour-integration-trigger-grid',
integrationAgentStrategyEmpty: 'step-by-step-tour-integration-agent-strategy-empty',
integrationExtensionGrid: 'step-by-step-tour-integration-extension-grid',
integrationCustomEndpointEmpty: 'step-by-step-tour-integration-custom-endpoint-empty',
} as const
type StepByStepTourGuideCopyKey = I18nKeysWithPrefix<'common', 'stepByStepTour.'>
export type StepByStepTourGuideKind = 'action' | 'walkthrough'
export type StepByStepTourGuideInteractionPolicy = 'blocked' | 'target-only'
type StepByStepTourGuidePortalOrder = 'afterOverlays'
export type StepByStepTourGuide = {
id: string
taskId: StepByStepTourTaskId
target: string
title: StepByStepTourGuideCopyKey
description: StepByStepTourGuideCopyKey
learnMoreLabel: StepByStepTourGuideCopyKey
primaryActionLabel: StepByStepTourGuideCopyKey
completionMode?: 'guideAction' | 'external'
kind?: StepByStepTourGuideKind
interactionPolicy?: StepByStepTourGuideInteractionPolicy
highlightPartSelectors?: string[]
integrationSection?: IntegrationSection
learnMoreDocPath?: DocPathWithoutLang
optional?: boolean
portalOrder?: StepByStepTourGuidePortalOrder
}
export function getStepByStepTourGuideKind(
guide: Pick<StepByStepTourGuide, 'completionMode' | 'kind'>,
): StepByStepTourGuideKind {
return guide.kind ?? (guide.completionMode === 'external' ? 'action' : 'walkthrough')
}
export function getStepByStepTourGuideInteractionPolicy(
guide: StepByStepTourGuide,
canClickThrough: boolean,
): StepByStepTourGuideInteractionPolicy {
if (guide.interactionPolicy) return guide.interactionPolicy
if (getStepByStepTourGuideKind(guide) === 'action')
return canClickThrough ? 'target-only' : 'blocked'
return 'blocked'
}
const STEP_BY_STEP_TOUR_STUDIO_GUIDES: Record<
Extract<
StepByStepTourGuideGroup,
'studioEmpty' | 'studioWithApps' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps'
>,
StepByStepTourGuide[]
> = {
studioEmpty: [
{
id: 'studio.empty.template',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate,
title: 'stepByStepTour.guides.studio.empty.template.title',
description: 'stepByStepTour.guides.studio.empty.template.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
{
id: 'studio.empty.blank',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank,
title: 'stepByStepTour.guides.studio.empty.blank.title',
description: 'stepByStepTour.guides.studio.empty.blank.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
{
id: 'studio.empty.dsl',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL,
title: 'stepByStepTour.guides.studio.empty.dsl.title',
description: 'stepByStepTour.guides.studio.empty.dsl.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
{
id: 'studio.empty.learn_dify',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify,
title: 'stepByStepTour.guides.studio.empty.learnDify.title',
description: 'stepByStepTour.guides.studio.empty.learnDify.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
optional: true,
},
],
studioWithApps: [
{
id: 'studio.with_apps.create',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate,
title: 'stepByStepTour.guides.studio.withApps.create.title',
description: 'stepByStepTour.guides.studio.withApps.create.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
highlightPartSelectors: [
getStepByStepTourHighlightPartSelector(STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu),
],
},
{
id: 'studio.with_apps.manage',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard,
title: 'stepByStepTour.guides.studio.withApps.manage.title',
description: 'stepByStepTour.guides.studio.withApps.manage.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
highlightPartSelectors: [
getStepByStepTourHighlightPartSelector(
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu,
),
],
optional: true,
},
],
studioNoCreateEmpty: [
{
id: 'studio.no_create.empty',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty,
title: 'stepByStepTour.guides.studio.noCreate.empty.title',
description: 'stepByStepTour.guides.studio.noCreate.empty.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
],
studioNoCreateWithApps: [
{
id: 'studio.no_create.with_apps.manage',
taskId: 'studio',
target: STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard,
title: 'stepByStepTour.guides.studio.noCreate.withApps.title',
description: 'stepByStepTour.guides.studio.noCreate.withApps.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
highlightPartSelectors: [
getStepByStepTourHighlightPartSelector(
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard,
),
],
},
],
}
const STEP_BY_STEP_TOUR_KNOWLEDGE_GUIDES: Record<
Extract<StepByStepTourGuideGroup, 'knowledgeEmpty' | 'knowledgeWithDatasets'>,
StepByStepTourGuide[]
> = {
knowledgeEmpty: [
{
id: 'knowledge.empty.create',
taskId: 'knowledge',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate,
title: 'stepByStepTour.guides.knowledge.empty.create.title',
description: 'stepByStepTour.guides.knowledge.empty.create.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
{
id: 'knowledge.empty.pipeline',
taskId: 'knowledge',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline,
title: 'stepByStepTour.guides.knowledge.empty.pipeline.title',
description: 'stepByStepTour.guides.knowledge.empty.pipeline.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
{
id: 'knowledge.empty.connect',
taskId: 'knowledge',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect,
title: 'stepByStepTour.guides.knowledge.empty.connect.title',
description: 'stepByStepTour.guides.knowledge.empty.connect.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
],
knowledgeWithDatasets: [
{
id: 'knowledge.with_datasets.create',
taskId: 'knowledge',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate,
title: 'stepByStepTour.guides.knowledge.withDatasets.create.title',
description: 'stepByStepTour.guides.knowledge.withDatasets.create.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
highlightPartSelectors: [
getStepByStepTourHighlightPartSelector(
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu,
),
],
},
{
id: 'knowledge.with_datasets.manage',
taskId: 'knowledge',
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard,
title: 'stepByStepTour.guides.knowledge.withDatasets.manage.title',
description: 'stepByStepTour.guides.knowledge.withDatasets.manage.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
highlightPartSelectors: [
getStepByStepTourHighlightPartSelector(
STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu,
),
],
optional: true,
},
],
}
const STEP_BY_STEP_TOUR_GUIDES: Partial<Record<StepByStepTourTaskId, StepByStepTourGuide[]>> = {
home: [
{
id: 'home.open_lesson',
taskId: 'home',
target: STEP_BY_STEP_TOUR_TARGETS.home,
title: 'stepByStepTour.tasks.home.title',
description: 'stepByStepTour.guides.home.pick.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.tasks.home.primaryActionLabel',
completionMode: 'external',
kind: 'action',
interactionPolicy: 'target-only',
},
{
id: 'home.create_app',
taskId: 'home',
target: STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
title: 'stepByStepTour.tasks.home.title',
description: 'stepByStepTour.guides.home.create.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
completionMode: 'external',
kind: 'action',
interactionPolicy: 'target-only',
portalOrder: 'afterOverlays',
},
],
integration: [
{
id: 'integration.model_provider',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav,
title: 'stepByStepTour.guides.integration.modelProvider.title',
description: 'stepByStepTour.guides.integration.modelProvider.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'provider',
},
{
id: 'integration.tool_plugin',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav,
title: 'stepByStepTour.guides.integration.toolPlugin.title',
description: 'stepByStepTour.guides.integration.toolPlugin.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'builtin',
},
{
id: 'integration.mcp',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav,
title: 'stepByStepTour.guides.integration.mcp.title',
description: 'stepByStepTour.guides.integration.mcp.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'mcp',
},
{
id: 'integration.data_source',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav,
title: 'stepByStepTour.guides.integration.dataSource.title',
description: 'stepByStepTour.guides.integration.dataSource.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'data-source',
},
{
id: 'integration.trigger',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav,
title: 'stepByStepTour.guides.integration.trigger.title',
description: 'stepByStepTour.guides.integration.trigger.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'trigger',
},
{
id: 'integration.update_settings',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationUpdateSettings,
title: 'stepByStepTour.guides.integration.updateSettings.title',
description: 'stepByStepTour.guides.integration.updateSettings.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'builtin',
optional: true,
},
],
}
const STEP_BY_STEP_TOUR_HOME_NO_CREATE_GUIDES: StepByStepTourGuide[] = [
{
id: 'home.no_create',
taskId: 'home',
target: STEP_BY_STEP_TOUR_TARGETS.home,
title: 'stepByStepTour.guides.home.noCreate.title',
description: 'stepByStepTour.guides.home.noCreate.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
},
]
const STEP_BY_STEP_TOUR_INTEGRATION_LIMITED_ACCESS_GUIDES: StepByStepTourGuide[] = [
{
id: 'integration.limited_access.model_provider',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav,
title: 'stepByStepTour.guides.integration.modelProvider.title',
description: 'stepByStepTour.guides.integration.limitedAccess.modelProvider.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'provider',
},
{
id: 'integration.limited_access.tool_plugin',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationToolPluginNav,
title: 'stepByStepTour.guides.integration.toolPlugin.title',
description: 'stepByStepTour.guides.integration.limitedAccess.toolPlugin.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'builtin',
},
{
id: 'integration.limited_access.mcp',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationMcpNav,
title: 'stepByStepTour.guides.integration.mcp.title',
description: 'stepByStepTour.guides.integration.limitedAccess.mcp.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'mcp',
},
{
id: 'integration.limited_access.data_source',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceNav,
title: 'stepByStepTour.guides.integration.dataSource.title',
description: 'stepByStepTour.guides.integration.limitedAccess.dataSource.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'data-source',
},
{
id: 'integration.limited_access.trigger',
taskId: 'integration',
target: STEP_BY_STEP_TOUR_TARGETS.integrationTriggerNav,
title: 'stepByStepTour.guides.integration.trigger.title',
description: 'stepByStepTour.guides.integration.limitedAccess.trigger.description',
learnMoreLabel: 'stepByStepTour.learnMore',
primaryActionLabel: 'stepByStepTour.guides.primaryActionLabel',
integrationSection: 'trigger',
},
]
const isStepByStepTourHomeGuideGroup = (
guideGroup?: StepByStepTourGuideGroup,
): guideGroup is Extract<StepByStepTourGuideGroup, 'homeNoCreate'> => guideGroup === 'homeNoCreate'
const isStepByStepTourStudioGuideGroup = (
guideGroup?: StepByStepTourGuideGroup,
): guideGroup is Extract<
StepByStepTourGuideGroup,
'studioEmpty' | 'studioWithApps' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps'
> =>
guideGroup === 'studioEmpty' ||
guideGroup === 'studioWithApps' ||
guideGroup === 'studioNoCreateEmpty' ||
guideGroup === 'studioNoCreateWithApps'
const isStepByStepTourKnowledgeGuideGroup = (
guideGroup?: StepByStepTourGuideGroup,
): guideGroup is Extract<StepByStepTourGuideGroup, 'knowledgeEmpty' | 'knowledgeWithDatasets'> =>
guideGroup === 'knowledgeEmpty' || guideGroup === 'knowledgeWithDatasets'
const isStepByStepTourIntegrationGuideGroup = (
guideGroup?: StepByStepTourGuideGroup,
): guideGroup is Extract<StepByStepTourGuideGroup, 'integrationLimitedAccess'> =>
guideGroup === 'integrationLimitedAccess'
export const getStepByStepTourGuides = (
taskId: StepByStepTourTaskId,
guideGroup?: StepByStepTourGuideGroup,
) => {
if (taskId === 'home' && isStepByStepTourHomeGuideGroup(guideGroup))
return STEP_BY_STEP_TOUR_HOME_NO_CREATE_GUIDES
if (taskId === 'studio')
return isStepByStepTourStudioGuideGroup(guideGroup)
? STEP_BY_STEP_TOUR_STUDIO_GUIDES[guideGroup]
: []
if (taskId === 'knowledge')
return isStepByStepTourKnowledgeGuideGroup(guideGroup)
? STEP_BY_STEP_TOUR_KNOWLEDGE_GUIDES[guideGroup]
: []
if (taskId === 'integration' && isStepByStepTourIntegrationGuideGroup(guideGroup))
return STEP_BY_STEP_TOUR_INTEGRATION_LIMITED_ACCESS_GUIDES
return STEP_BY_STEP_TOUR_GUIDES[taskId] ?? []
}
export function getStepByStepTourTargetSelector(target: string) {
return `[data-step-by-step-tour-target="${target}"]`
}
function getStepByStepTourHighlightPartSelector(target: string) {
return `[data-step-by-step-tour-highlight-part="${target}"]`
}

View File

@ -0,0 +1,44 @@
import type { StepByStepTourTaskDefinition } from './types'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { STEP_BY_STEP_TOUR_TARGETS } from './target-registry'
export const STEP_BY_STEP_TOUR_TASKS = [
{
id: 'home',
route: '/',
target: STEP_BY_STEP_TOUR_TARGETS.home,
iconClassName: 'i-custom-vender-line-education-book-open-01',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.home,
learnMoreDocPath: '/use-dify/getting-started/introduction',
canClickThrough: true,
},
{
id: 'studio',
route: '/apps',
target: STEP_BY_STEP_TOUR_TARGETS.studio,
iconClassName: 'i-custom-vender-main-nav-studio',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.studio,
learnMoreDocPath: '/use-dify/workspace/app-management',
canClickThrough: true,
},
{
id: 'knowledge',
route: '/datasets',
target: STEP_BY_STEP_TOUR_TARGETS.knowledge,
iconClassName: 'i-custom-vender-main-nav-knowledge',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.knowledge,
learnMoreDocPath: undefined,
canClickThrough: true,
permissionFallback: 'show-disabled-reason',
},
{
id: 'integration',
route: buildIntegrationPath('provider'),
target: STEP_BY_STEP_TOUR_TARGETS.integration,
iconClassName: 'i-custom-vender-main-nav-integrations',
fallbackTarget: STEP_BY_STEP_TOUR_TARGETS.integration,
learnMoreDocPath: '/use-dify/workspace/plugins',
canClickThrough: true,
permissionFallback: 'show-disabled-reason',
},
] as const satisfies readonly StepByStepTourTaskDefinition[]

View File

@ -0,0 +1,51 @@
import type { StepByStepTourStateResponse } from '@dify/contracts/api/console/onboarding/types.gen'
import type { DocPathWithoutLang } from '@/types/doc-paths'
export type StepByStepTourTaskId = NonNullable<
StepByStepTourStateResponse['completed_task_ids']
>[number]
type StepByStepTourTaskStatus = 'completed' | 'current' | 'pending' | 'disabled'
export type StepByStepTourGuideGroup =
| 'homeNoCreate'
| 'studioEmpty'
| 'studioWithApps'
| 'studioNoCreateEmpty'
| 'studioNoCreateWithApps'
| 'knowledgeEmpty'
| 'knowledgeWithDatasets'
| 'integrationLimitedAccess'
export type StepByStepTourSessionState = {
activeTaskId?: StepByStepTourTaskId
activeGuideIndex?: number
activeGuideGroup?: StepByStepTourGuideGroup
activeGuideIndexes?: number[]
}
type StepByStepTourPermissionFallback = 'show-parent-empty-state' | 'show-disabled-reason'
export type StepByStepTourTaskDefinition = {
id: StepByStepTourTaskId
route: string
target: string
iconClassName: string
fallbackTarget?: string
learnMoreDocPath?: DocPathWithoutLang
canClickThrough: boolean
permissionFallback?: StepByStepTourPermissionFallback
}
export type StepByStepTourTaskView = {
id: StepByStepTourTaskId
title: string
description: string
iconClassName: string
status: StepByStepTourTaskStatus
primaryActionLabel: string
disabledReason?: string
canToggleCompletion?: boolean
learnMoreLabel?: string
learnMoreHref?: string
}

View File

@ -0,0 +1,157 @@
'use client'
import type { CSSProperties } from 'react'
import type { StepByStepTourTargetRect } from './use-target-rect'
import { useLayoutEffect, useState } from 'react'
const BUBBLE_WIDTH = 352
const BUBBLE_HEIGHT = 158
const BUBBLE_SIDE_OFFSET = 20
const VIEWPORT_PADDING = 8
const FIGMA_ARROW_FRAME_LEFT = 28
const FIGMA_ARROW_DOT_CENTER_X = 1
const MIN_ARROW_LEFT = 12
const MAX_ARROW_RIGHT_PADDING = 12
type ViewportSize = {
height: number
width: number
}
export type StepByStepTourCoachmarkSize = {
height: number
width: number
}
type CoachmarkPosition = {
arrowStyle: CSSProperties
bubbleStyle: CSSProperties
placement: StepByStepTourCoachmarkPlacement
}
export type StepByStepTourCoachmarkPlacement = 'bottom' | 'right' | 'top'
const getViewportSize = (): ViewportSize => ({
height: window.innerHeight,
width: window.innerWidth,
})
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max)
}
const getStepByStepTourCoachmarkPosition = (
placementRect: StepByStepTourTargetRect,
viewportSize: ViewportSize,
placement: StepByStepTourCoachmarkPlacement = 'bottom',
anchorRect: StepByStepTourTargetRect = placementRect,
bubbleSize: StepByStepTourCoachmarkSize = {
height: BUBBLE_HEIGHT,
width: BUBBLE_WIDTH,
},
): CoachmarkPosition => {
if (placement === 'right') {
const maxBubbleLeft = viewportSize.width - bubbleSize.width - VIEWPORT_PADDING
const bubbleLeft = clamp(
placementRect.left + placementRect.width + BUBBLE_SIDE_OFFSET,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleLeft),
)
const maxBubbleTop = viewportSize.height - bubbleSize.height - VIEWPORT_PADDING
const preferredBubbleTop = anchorRect.top + (anchorRect.height - bubbleSize.height) / 2
const bubbleTop = clamp(
preferredBubbleTop,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleTop),
)
return {
arrowStyle: {
top: anchorRect.top + anchorRect.height / 2 - bubbleTop,
},
bubbleStyle: {
left: bubbleLeft,
top: bubbleTop,
},
placement,
}
}
const maxBubbleLeft = viewportSize.width - bubbleSize.width - VIEWPORT_PADDING
const bubbleLeft = clamp(
placementRect.left,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleLeft),
)
const maxBubbleTop = viewportSize.height - bubbleSize.height - VIEWPORT_PADDING
const bottomBubbleTop = placementRect.top + placementRect.height + BUBBLE_SIDE_OFFSET
const topBubbleTop = placementRect.top - bubbleSize.height - BUBBLE_SIDE_OFFSET
const hasBottomRoom =
bottomBubbleTop + bubbleSize.height <= viewportSize.height - VIEWPORT_PADDING
const hasTopRoom = topBubbleTop >= VIEWPORT_PADDING
const spaceBelow = viewportSize.height - VIEWPORT_PADDING - bottomBubbleTop
const spaceAbove = placementRect.top - VIEWPORT_PADDING - BUBBLE_SIDE_OFFSET
const resolvedPlacement =
placement === 'bottom'
? !hasBottomRoom && (hasTopRoom || spaceAbove > spaceBelow)
? 'top'
: 'bottom'
: !hasTopRoom && (hasBottomRoom || spaceBelow > spaceAbove)
? 'bottom'
: 'top'
const preferredBubbleTop =
resolvedPlacement === 'top'
? placementRect.top - bubbleSize.height - BUBBLE_SIDE_OFFSET
: placementRect.top + placementRect.height + BUBBLE_SIDE_OFFSET
const bubbleTop = clamp(
preferredBubbleTop,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, maxBubbleTop),
)
const targetAnchorX = anchorRect.left + FIGMA_ARROW_FRAME_LEFT + FIGMA_ARROW_DOT_CENTER_X
const arrowLeft = clamp(
targetAnchorX - bubbleLeft - FIGMA_ARROW_DOT_CENTER_X,
MIN_ARROW_LEFT,
bubbleSize.width - MAX_ARROW_RIGHT_PADDING,
)
return {
arrowStyle: {
left: arrowLeft,
},
bubbleStyle: {
left: bubbleLeft,
top: bubbleTop,
},
placement: resolvedPlacement,
}
}
export const useStepByStepTourCoachmarkPosition = (
placementRect: StepByStepTourTargetRect,
placement: StepByStepTourCoachmarkPlacement = 'bottom',
anchorRect: StepByStepTourTargetRect = placementRect,
bubbleSize?: StepByStepTourCoachmarkSize,
) => {
const [viewportSize, setViewportSize] = useState<ViewportSize>(() => getViewportSize())
useLayoutEffect(() => {
const syncViewportSize = () => {
setViewportSize(getViewportSize())
}
window.addEventListener('resize', syncViewportSize)
return () => {
window.removeEventListener('resize', syncViewportSize)
}
}, [])
return getStepByStepTourCoachmarkPosition(
placementRect,
viewportSize,
placement,
anchorRect,
bubbleSize,
)
}

View File

@ -0,0 +1,266 @@
'use client'
import { useLayoutEffect, useRef, useState } from 'react'
export type StepByStepTourTargetRect = {
height: number
left: number
top: number
width: number
}
export type StepByStepTourTargetRects = {
anchorRect: StepByStepTourTargetRect
highlightPartsReady: boolean
highlightRect: StepByStepTourTargetRect
rectSettled: boolean
targetElement: HTMLElement
}
const EMPTY_HIGHLIGHT_PART_SELECTORS: string[] = []
const MAX_RECT_SETTLE_FRAMES = 8
const ATTRIBUTE_OBSERVER_OPTIONS: MutationObserverInit = {
attributeFilter: [
'class',
'data-align',
'data-ending-style',
'data-side',
'data-starting-style',
'hidden',
'style',
],
attributes: true,
}
const rectFromDOMRect = (rect: DOMRect): StepByStepTourTargetRect => ({
height: rect.height,
left: rect.left,
top: rect.top,
width: rect.width,
})
const unionTargetRects = (rects: StepByStepTourTargetRect[]): StepByStepTourTargetRect => {
const left = Math.min(...rects.map((rect) => rect.left))
const top = Math.min(...rects.map((rect) => rect.top))
const right = Math.max(...rects.map((rect) => rect.left + rect.width))
const bottom = Math.max(...rects.map((rect) => rect.top + rect.height))
return {
height: bottom - top,
left,
top,
width: right - left,
}
}
const getElementsBySelectors = (selectors: string[]) =>
selectors.flatMap((selector) => Array.from(document.querySelectorAll<HTMLElement>(selector)))
const getVisibleElementRect = (element: HTMLElement) => {
const rect = element.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return rectFromDOMRect(rect)
}
const getHighlightPartRectState = (selectors: string[]) => {
const rectsBySelector = selectors.map((selector) =>
Array.from(document.querySelectorAll<HTMLElement>(selector))
.map(getVisibleElementRect)
.filter((rect): rect is StepByStepTourTargetRect => Boolean(rect)),
)
return {
ready: rectsBySelector.every((rects) => rects.length > 0),
rects: rectsBySelector.flat(),
}
}
const getTargetRects = (targetElement: HTMLElement, highlightPartSelectors: string[] = []) => {
const anchorRect = rectFromDOMRect(targetElement.getBoundingClientRect())
const highlightPartRectState = getHighlightPartRectState(highlightPartSelectors)
const rects = [anchorRect, ...highlightPartRectState.rects]
return {
anchorRect,
highlightPartsReady: highlightPartRectState.ready,
highlightRect: unionTargetRects(rects),
targetElement,
}
}
const getInitialTargetRects = (
targetElement: HTMLElement,
highlightPartSelectors: string[] = [],
): StepByStepTourTargetRects => ({
...getTargetRects(targetElement, highlightPartSelectors),
rectSettled: highlightPartSelectors.length === 0,
})
const areTargetRectsEqual = (
currentRect: StepByStepTourTargetRect,
nextRect: StepByStepTourTargetRect,
) => {
return (
currentRect.height === nextRect.height &&
currentRect.left === nextRect.left &&
currentRect.top === nextRect.top &&
currentRect.width === nextRect.width
)
}
const areTargetRectSetsEqual = (
currentRects: StepByStepTourTargetRects,
nextRects: StepByStepTourTargetRects,
) => {
return (
areTargetRectsEqual(currentRects.anchorRect, nextRects.anchorRect) &&
currentRects.highlightPartsReady === nextRects.highlightPartsReady &&
areTargetRectsEqual(currentRects.highlightRect, nextRects.highlightRect) &&
currentRects.rectSettled === nextRects.rectSettled &&
currentRects.targetElement === nextRects.targetElement
)
}
const areMeasuredTargetRectSetsEqual = (
currentRects: Omit<StepByStepTourTargetRects, 'rectSettled'>,
nextRects: Omit<StepByStepTourTargetRects, 'rectSettled'>,
) => {
return (
areTargetRectsEqual(currentRects.anchorRect, nextRects.anchorRect) &&
currentRects.highlightPartsReady === nextRects.highlightPartsReady &&
areTargetRectsEqual(currentRects.highlightRect, nextRects.highlightRect) &&
currentRects.targetElement === nextRects.targetElement
)
}
export const useStepByStepTourTargetRect = (
targetElement: HTMLElement,
highlightPartSelectors: string[] = EMPTY_HIGHLIGHT_PART_SELECTORS,
) => {
const [targetRects, setTargetRects] = useState(() =>
getInitialTargetRects(targetElement, highlightPartSelectors),
)
const targetRectsRef = useRef(targetRects)
targetRectsRef.current = targetRects
useLayoutEffect(() => {
let animationFrame = 0
let resizeObserver: ResizeObserver | undefined
let mutationObserver: MutationObserver | undefined
let previousMeasuredRects: Omit<StepByStepTourTargetRects, 'rectSettled'> | undefined
let settleFrameCount = 0
const observedResizeElements = new Set<HTMLElement>()
const observedAttributeElements = new Set<HTMLElement>()
function observeResizeElement(element: HTMLElement) {
if (observedResizeElements.has(element)) return
observedResizeElements.add(element)
resizeObserver?.observe(element)
}
function observeAttributeElement(element: HTMLElement) {
if (!mutationObserver || observedAttributeElements.has(element)) return
observedAttributeElements.add(element)
mutationObserver.observe(element, ATTRIBUTE_OBSERVER_OPTIONS)
}
function observeHighlightPart(element: HTMLElement) {
// Floating UI positioners already run resize-driven layout internally.
// Attribute and mount observers are enough to remeasure their stable rects.
observeAttributeElement(element)
if (element.parentElement) observeAttributeElement(element.parentElement)
}
function syncRect() {
animationFrame = 0
const highlightPartElements = getElementsBySelectors(highlightPartSelectors)
highlightPartElements.forEach(observeHighlightPart)
const nextMeasuredRects = getTargetRects(targetElement, highlightPartSelectors)
if (highlightPartSelectors.length === 0) {
previousMeasuredRects = nextMeasuredRects
settleFrameCount = 0
commitTargetRects({
...nextMeasuredRects,
rectSettled: true,
})
return
}
if (!nextMeasuredRects.highlightPartsReady) {
previousMeasuredRects = undefined
settleFrameCount = 0
commitTargetRects({
...nextMeasuredRects,
rectSettled: false,
})
return
}
const rectSettled = previousMeasuredRects
? areMeasuredTargetRectSetsEqual(previousMeasuredRects, nextMeasuredRects)
: false
const shouldCommitMeasuredRect = rectSettled || settleFrameCount >= MAX_RECT_SETTLE_FRAMES
previousMeasuredRects = nextMeasuredRects
if (shouldCommitMeasuredRect) {
settleFrameCount = 0
commitTargetRects({
...nextMeasuredRects,
rectSettled: true,
})
return
}
settleFrameCount += 1
const nextRects = {
...nextMeasuredRects,
rectSettled: false,
}
commitTargetRects(nextRects)
scheduleSyncRect()
}
function commitTargetRects(nextRects: StepByStepTourTargetRects) {
if (areTargetRectSetsEqual(targetRectsRef.current, nextRects)) return
targetRectsRef.current = nextRects
setTargetRects(nextRects)
}
function scheduleSyncRect() {
if (animationFrame) return
animationFrame = window.requestAnimationFrame(syncRect)
}
resizeObserver =
typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(scheduleSyncRect)
observeResizeElement(targetElement)
mutationObserver =
typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(scheduleSyncRect)
mutationObserver?.observe(document.body, {
childList: true,
subtree: true,
})
observeAttributeElement(targetElement)
window.addEventListener('resize', scheduleSyncRect)
window.addEventListener('scroll', scheduleSyncRect, true)
syncRect()
return () => {
if (animationFrame) window.cancelAnimationFrame(animationFrame)
resizeObserver?.disconnect()
mutationObserver?.disconnect()
window.removeEventListener('resize', scheduleSyncRect)
window.removeEventListener('scroll', scheduleSyncRect, true)
}
}, [highlightPartSelectors, targetElement])
return targetRects
}

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