mirror of
https://github.com/langgenius/dify.git
synced 2026-07-25 13:38:31 +08:00
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:
parent
e77ee82526
commit
cb64446fa3
@ -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):
|
||||
"""
|
||||
|
||||
@ -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",
|
||||
|
||||
106
api/controllers/console/onboarding.py
Normal file
106
api/controllers/console/onboarding.py
Normal 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,
|
||||
),
|
||||
)
|
||||
@ -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")
|
||||
@ -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
59
api/models/onboarding.py
Normal 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(),
|
||||
)
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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]:
|
||||
|
||||
221
api/services/step_by_step_tour_service.py
Normal file
221
api/services/step_by_step_tour_service.py
Normal 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
|
||||
99
api/tests/unit_tests/controllers/console/test_onboarding.py
Normal file
99
api/tests/unit_tests/controllers/console/test_onboarding.py
Normal 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"})
|
||||
@ -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
|
||||
|
||||
238
api/tests/unit_tests/services/test_step_by_step_tour_service.py
Normal file
238
api/tests/unit_tests/services/test_step_by_step_tour_service.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
@ -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]
|
||||
@ -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
|
||||
@ -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: () =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 |
@ -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>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 330,
|
||||
"total": 331,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -77,6 +77,7 @@ const defaultProviderContext = {
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
hasBlockingModalOpen: false,
|
||||
setShowAccountSettingModal: noop,
|
||||
setShowModerationSettingModal: noop,
|
||||
setShowExternalDataToolModal: noop,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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' })} />
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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} />)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -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} />)
|
||||
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
106
web/app/components/explore/learn-dify/__tests__/index.spec.tsx
Normal file
106
web/app/components/explore/learn-dify/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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} />
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -20,7 +20,7 @@ const ToolProviderCreateAction = ({
|
||||
onCustomToolCreated,
|
||||
onMCPProviderCreated,
|
||||
}: ToolProviderCreateActionProps) => {
|
||||
if (activeTab === 'mcp' && hasCategoryCollections)
|
||||
if (activeTab === 'mcp')
|
||||
return (
|
||||
<NewMCPButton
|
||||
handleCreate={(provider: ToolWithProvider) => onMCPProviderCreated(provider.id)}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
37
web/app/components/main-nav/components/help-menu.module.css
Normal file
37
web/app/components/main-nav/components/help-menu.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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) ====================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
11
web/app/components/step-by-step-tour/README.md
Normal file
11
web/app/components/step-by-step-tour/README.md
Normal 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.
|
||||
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
1401
web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
Normal file
1401
web/app/components/step-by-step-tour/__tests__/mount.spec.tsx
Normal file
File diff suppressed because it is too large
Load Diff
464
web/app/components/step-by-step-tour/__tests__/state.spec.ts
Normal file
464
web/app/components/step-by-step-tour/__tests__/state.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
74
web/app/components/step-by-step-tour/analytics.ts
Normal file
74
web/app/components/step-by-step-tour/analytics.ts
Normal 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))
|
||||
}
|
||||
387
web/app/components/step-by-step-tour/coachmark.tsx
Normal file
387
web/app/components/step-by-step-tour/coachmark.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
188
web/app/components/step-by-step-tour/dropdown-menu.ts
Normal file
188
web/app/components/step-by-step-tour/dropdown-menu.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
367
web/app/components/step-by-step-tour/floating-widget.tsx
Normal file
367
web/app/components/step-by-step-tour/floating-widget.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
805
web/app/components/step-by-step-tour/mount.tsx
Normal file
805
web/app/components/step-by-step-tour/mount.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
391
web/app/components/step-by-step-tour/state.ts
Normal file
391
web/app/components/step-by-step-tour/state.ts
Normal 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)
|
||||
},
|
||||
)
|
||||
19
web/app/components/step-by-step-tour/storage.ts
Normal file
19
web/app/components/step-by-step-tour/storage.ts
Normal 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 }
|
||||
480
web/app/components/step-by-step-tour/target-registry.ts
Normal file
480
web/app/components/step-by-step-tour/target-registry.ts
Normal 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}"]`
|
||||
}
|
||||
44
web/app/components/step-by-step-tour/tasks.ts
Normal file
44
web/app/components/step-by-step-tour/tasks.ts
Normal 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[]
|
||||
51
web/app/components/step-by-step-tour/types.ts
Normal file
51
web/app/components/step-by-step-tour/types.ts
Normal 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
|
||||
}
|
||||
157
web/app/components/step-by-step-tour/use-coachmark-position.ts
Normal file
157
web/app/components/step-by-step-tour/use-coachmark-position.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
266
web/app/components/step-by-step-tour/use-target-rect.ts
Normal file
266
web/app/components/step-by-step-tour/use-target-rect.ts
Normal 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
Loading…
Reference in New Issue
Block a user