mirror of
https://github.com/langgenius/dify.git
synced 2026-07-08 06:16:36 +08:00
Compare commits
6 Commits
deploy/saa
...
0707-datas
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a724534e2 | |||
| e66a9eded1 | |||
| 11e6d48b7d | |||
| 56643b9916 | |||
| 4651d70c05 | |||
| 5b0381ba3d |
@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
@ -1094,16 +1094,6 @@ class HomepageConfig(BaseSettings):
|
||||
default=True,
|
||||
)
|
||||
|
||||
ENABLE_STEP_BY_STEP_TOUR: bool = Field(
|
||||
description="Enable account-level Step-by-step Tour eligibility checks",
|
||||
default=False,
|
||||
)
|
||||
|
||||
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT: datetime | None = Field(
|
||||
description="UTC timestamp after which newly initialized accounts are eligible for Step-by-step Tour",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class RagEtlConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@ -39,7 +39,6 @@ from . import (
|
||||
human_input_form,
|
||||
init_validate,
|
||||
notification,
|
||||
onboarding,
|
||||
ping,
|
||||
setup,
|
||||
spec,
|
||||
@ -207,7 +206,6 @@ __all__ = [
|
||||
"notification",
|
||||
"oauth",
|
||||
"oauth_server",
|
||||
"onboarding",
|
||||
"ops_trace",
|
||||
"parameter",
|
||||
"ping",
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
"""Console onboarding APIs.
|
||||
|
||||
This module keeps Step-by-step Tour persistence account-scoped. Workspace IDs
|
||||
are accepted only as presentation overrides; UI-only state such as minimized
|
||||
panels or the currently active task stays on the frontend. PATCH requests are
|
||||
action-based so callers do not replace server-side arrays with stale snapshots.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
|
||||
|
||||
|
||||
class StepByStepTourStatePatchPayload(BaseModel):
|
||||
action: StepByStepTourAction = Field(description="State update action")
|
||||
task_id: StepByStepTourTaskId | None = Field(default=None, description="Task ID for task actions")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_patch_shape(self) -> "StepByStepTourStatePatchPayload":
|
||||
task_actions = {"complete_task", "uncomplete_task"}
|
||||
if self.action in task_actions and self.task_id is None:
|
||||
raise ValueError("task_id is required for task actions")
|
||||
if self.action not in task_actions and self.task_id is not None:
|
||||
raise ValueError("task_id is only supported for task actions")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(ResponseModel):
|
||||
eligible: bool
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: list[StepByStepTourTaskId] = Field(default_factory=list)
|
||||
manually_enabled_workspace_ids: list[str] = Field(default_factory=list)
|
||||
manually_disabled_workspace_ids: list[str] = Field(default_factory=list)
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, StepByStepTourStatePatchPayload)
|
||||
register_response_schema_models(console_ns, StepByStepTourStateResponse)
|
||||
|
||||
|
||||
@console_ns.route("/onboarding/step-by-step-tour/state")
|
||||
class StepByStepTourStateApi(Resource):
|
||||
@console_ns.doc("get_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Get account-level Step-by-step Tour state")
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.get_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
session=db.session,
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.doc("patch_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Update account-level Step-by-step Tour state")
|
||||
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, current_user: Account):
|
||||
payload = StepByStepTourStatePatchPayload.model_validate(console_ns.payload or {})
|
||||
patch = cast(StepByStepTourPatch, payload.model_dump(exclude_unset=True, exclude_none=True))
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
patch=patch,
|
||||
session=db.session,
|
||||
),
|
||||
)
|
||||
@ -1,39 +0,0 @@
|
||||
"""add step by step tour state
|
||||
|
||||
Revision ID: e4f5a6b7c8d9
|
||||
Revises: d9e8f7a6b5c4
|
||||
Create Date: 2026-06-29 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e4f5a6b7c8d9"
|
||||
down_revision = "d9e8f7a6b5c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"account_step_by_step_tour_states",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("first_workspace_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("skipped", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("completed_task_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("manually_enabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("manually_disabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
|
||||
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("account_step_by_step_tour_states")
|
||||
@ -101,7 +101,6 @@ from .model import (
|
||||
UploadFile,
|
||||
)
|
||||
from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken
|
||||
from .onboarding import AccountStepByStepTourState
|
||||
from .provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@ -155,7 +154,6 @@ __all__ = [
|
||||
"Account",
|
||||
"AccountIntegrate",
|
||||
"AccountStatus",
|
||||
"AccountStepByStepTourState",
|
||||
"AccountTrialAppRecord",
|
||||
"Agent",
|
||||
"AgentConfigDraft",
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
"""Account-level onboarding state models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import TypeBase, gen_uuidv7_string
|
||||
from .types import AdjustedJSON, StringUUID
|
||||
|
||||
|
||||
class AccountStepByStepTourState(TypeBase):
|
||||
"""Persistent account-level Step-by-step Tour state.
|
||||
|
||||
The tour is account-owned, with workspace IDs stored only as presentation
|
||||
overrides. The first workspace is the workspace context where an eligible
|
||||
account first asks for tour state; subsequent workspaces are opt-in only.
|
||||
"""
|
||||
|
||||
__tablename__ = "account_step_by_step_tour_states"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
|
||||
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
insert_default=gen_uuidv7_string,
|
||||
default_factory=gen_uuidv7_string,
|
||||
init=False,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
first_workspace_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
skipped: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
|
||||
completed_task_ids: Mapped[list[str]] = mapped_column(AdjustedJSON, nullable=False, default_factory=list)
|
||||
manually_enabled_workspace_ids: Mapped[list[str]] = mapped_column(
|
||||
AdjustedJSON,
|
||||
nullable=False,
|
||||
default_factory=list,
|
||||
)
|
||||
manually_disabled_workspace_ids: Mapped[list[str]] = mapped_column(
|
||||
AdjustedJSON,
|
||||
nullable=False,
|
||||
default_factory=list,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.current_timestamp(),
|
||||
nullable=False,
|
||||
init=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.current_timestamp(),
|
||||
nullable=False,
|
||||
init=False,
|
||||
onupdate=func.current_timestamp(),
|
||||
)
|
||||
@ -182,7 +182,6 @@ class SystemFeatureModel(FeatureResponseModel):
|
||||
enable_trial_app: bool = False
|
||||
enable_explore_banner: bool = False
|
||||
enable_learn_app: bool = True
|
||||
enable_step_by_step_tour: bool = False
|
||||
rbac_enabled: bool = False
|
||||
|
||||
|
||||
@ -285,7 +284,6 @@ class FeatureService:
|
||||
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
|
||||
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
|
||||
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
|
||||
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
|
||||
|
||||
@classmethod
|
||||
def _fulfill_trial_models_from_env(cls) -> list[str]:
|
||||
|
||||
@ -416,9 +416,10 @@ class PluginMigration:
|
||||
data = _tenant_plugin_adapter.validate_json(line)
|
||||
tenant_id = data["tenant_id"]
|
||||
plugin_ids = data["plugins"]
|
||||
plugin_not_exist = [
|
||||
plugin_id for plugin_id in plugin_ids if plugin_id not in package_identifier_by_plugin_id
|
||||
]
|
||||
plugin_not_exist: list[str] = []
|
||||
for plugin_id in plugin_ids:
|
||||
if plugin_id not in package_identifier_by_plugin_id:
|
||||
plugin_not_exist.append(plugin_id)
|
||||
|
||||
if plugin_not_exist:
|
||||
not_installed.append(
|
||||
|
||||
@ -1,225 +0,0 @@
|
||||
"""Account-level Step-by-step Tour persistence."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import ensure_naive_utc
|
||||
from models.account import Account
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
|
||||
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(TypedDict):
|
||||
eligible: bool
|
||||
first_workspace_id: str | None
|
||||
skipped: bool
|
||||
completed_task_ids: list[str]
|
||||
manually_enabled_workspace_ids: list[str]
|
||||
manually_disabled_workspace_ids: list[str]
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class StepByStepTourPatch(TypedDict):
|
||||
action: str
|
||||
task_id: NotRequired[str | None]
|
||||
|
||||
|
||||
class StepByStepTourService:
|
||||
"""Coordinate persisted tour state with account eligibility rules."""
|
||||
|
||||
@classmethod
|
||||
def get_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
eligible = cls.is_eligible(account)
|
||||
state = cls._get_state(account.id, session=session)
|
||||
|
||||
if eligible:
|
||||
state = cls._ensure_state(account.id, session=session, state=state)
|
||||
if state.first_workspace_id is None:
|
||||
state.first_workspace_id = current_tenant_id
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
|
||||
return cls._build_response(eligible=eligible, state=state)
|
||||
|
||||
@classmethod
|
||||
def patch_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
patch: StepByStepTourPatch,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
state = cls._ensure_state(account.id, session=session, state=None)
|
||||
cls._apply_action(
|
||||
state=state,
|
||||
action=patch["action"],
|
||||
task_id=patch.get("task_id"),
|
||||
current_tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
return cls._build_response(eligible=cls.is_eligible(account), state=state)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls, account: Account) -> bool:
|
||||
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
|
||||
return False
|
||||
|
||||
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
|
||||
if rollout_started_at is None:
|
||||
return False
|
||||
|
||||
account_started_at = account.initialized_at or account.created_at
|
||||
if account_started_at is None:
|
||||
return False
|
||||
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _get_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def _ensure_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> AccountStepByStepTourState:
|
||||
if state is None:
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(state)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# Another tab/device can create the account row between our read and insert.
|
||||
session.rollback()
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is None:
|
||||
raise
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _apply_action(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState,
|
||||
action: str,
|
||||
task_id: str | None,
|
||||
current_tenant_id: str,
|
||||
) -> None:
|
||||
match action:
|
||||
case "skip":
|
||||
state.skipped = True
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case "complete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
|
||||
case "uncomplete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
|
||||
case "enable_current_workspace":
|
||||
state.skipped = False
|
||||
state.manually_enabled_workspace_ids = cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case "disable_current_workspace":
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
@classmethod
|
||||
def _build_response(
|
||||
cls,
|
||||
*,
|
||||
eligible: bool,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> StepByStepTourStateResponse:
|
||||
if state is None:
|
||||
return {
|
||||
"eligible": eligible,
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"eligible": eligible,
|
||||
"first_workspace_id": state.first_workspace_id,
|
||||
"skipped": state.skipped,
|
||||
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
|
||||
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
|
||||
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
|
||||
"updated_at": getattr(state, "updated_at", None),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
|
||||
raise ValueError(f"Unsupported task_id: {task_id}")
|
||||
|
||||
@classmethod
|
||||
def _add_id(cls, values: list[str], value: str) -> list[str]:
|
||||
normalized = cls._normalize_ids(values)
|
||||
if value in normalized:
|
||||
return normalized
|
||||
return [*normalized, value]
|
||||
|
||||
@classmethod
|
||||
def _remove_id(cls, values: list[str], value: str) -> list[str]:
|
||||
return [item for item in cls._normalize_ids(values) if item != value]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
@ -1,101 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.onboarding import (
|
||||
StepByStepTourStateApi,
|
||||
StepByStepTourStatePatchPayload,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, AccountStatus
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
return account
|
||||
|
||||
|
||||
def _state_response() -> dict[str, object]:
|
||||
return {
|
||||
"eligible": True,
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
|
||||
}
|
||||
|
||||
|
||||
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
|
||||
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
|
||||
result = method(api, "workspace-1", _account())
|
||||
|
||||
assert result == {
|
||||
"eligible": True,
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": "2026-06-28T00:00:00Z",
|
||||
}
|
||||
get_state.assert_called_once()
|
||||
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert get_state.call_args.kwargs["session"] is db.session
|
||||
|
||||
|
||||
def test_patch_step_by_step_tour_state_passes_action_payload(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
patch_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
|
||||
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.patch)
|
||||
payload = {"action": "complete_task", "task_id": "studio"}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/onboarding/step-by-step-tour/state",
|
||||
method="PATCH",
|
||||
json=payload,
|
||||
):
|
||||
with patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload):
|
||||
result = method(api, "workspace-1", _account())
|
||||
|
||||
assert result["completed_task_ids"] == ["home"]
|
||||
patch_state.assert_called_once()
|
||||
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert patch_state.call_args.kwargs["patch"] == payload
|
||||
assert patch_state.call_args.kwargs["session"] is db.session
|
||||
|
||||
|
||||
def test_patch_payload_rejects_non_action_fields() -> None:
|
||||
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||||
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "skipped": True})
|
||||
|
||||
|
||||
def test_patch_payload_rejects_task_id_without_task_action() -> None:
|
||||
with pytest.raises(ValidationError, match="task_id is only supported for task actions"):
|
||||
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "task_id": "home"})
|
||||
|
||||
|
||||
def test_patch_payload_requires_action() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
|
||||
@ -148,35 +148,5 @@ class TestHandlePluginInstanceInstall:
|
||||
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
|
||||
|
||||
assert json.loads(output_file.read_text())["not_installed"] == [
|
||||
{
|
||||
"tenant_id": "tenant1",
|
||||
"plugin_not_exist": ["langgenius/missing"],
|
||||
}
|
||||
{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}
|
||||
]
|
||||
mock_installer.install_from_identifiers.assert_called_once()
|
||||
|
||||
def test_install_plugins_skips_unresolved_plugins(self, tmp_path) -> None:
|
||||
extracted_plugins = tmp_path / "plugins.jsonl"
|
||||
output_file = tmp_path / "output.json"
|
||||
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/missing"]}\n')
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
|
||||
return_value={
|
||||
"plugins": {},
|
||||
"plugin_not_exist": ["langgenius/missing"],
|
||||
},
|
||||
),
|
||||
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
):
|
||||
mock_installer = MagicMock()
|
||||
mock_installer.list_plugins.return_value = []
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
|
||||
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
|
||||
|
||||
output = json.loads(output_file.read_text())
|
||||
assert output["not_installed"] == [{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}]
|
||||
mock_installer.install_from_identifiers.assert_not_called()
|
||||
|
||||
@ -633,19 +633,6 @@ def test_import_rag_pipeline_yaml_content_requires_content() -> None:
|
||||
assert "yaml_content is required" in result.error
|
||||
|
||||
|
||||
def test_import_rag_pipeline_rejects_oversized_yaml_content_before_parsing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 3)
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content="你你")
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "File size exceeds the limit of 10MB"
|
||||
|
||||
|
||||
def test_import_rag_pipeline_yaml_content_requires_mapping() -> None:
|
||||
service = RagPipelineDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="t1")
|
||||
|
||||
@ -1,49 +1,50 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.app_dsl_service import AppDslService, ImportStatus
|
||||
|
||||
|
||||
def test_import_app_rejects_oversized_yaml_content_before_parsing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 3)
|
||||
service = AppDslService(session=Mock())
|
||||
account = Mock(current_tenant_id="tenant-1")
|
||||
def test_import_app_rejects_oversized_yaml_content_by_bytes(monkeypatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(account=account, import_mode="yaml-content", yaml_content="你你")
|
||||
result = service.import_app(
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-content",
|
||||
yaml_content="é",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "File size exceeds the limit of 10MB"
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch) -> None:
|
||||
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff\xff"
|
||||
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
|
||||
service = AppDslService(session=Mock())
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(
|
||||
account=Mock(current_tenant_id="tenant-1"),
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/app.yaml",
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == "File size exceeds the limit of 10MB"
|
||||
assert "10MB" in result.error
|
||||
|
||||
|
||||
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.content = b"\xff"
|
||||
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
|
||||
service = AppDslService(session=Mock())
|
||||
service = AppDslService(session=SimpleNamespace())
|
||||
|
||||
result = service.import_app(
|
||||
account=Mock(current_tenant_id="tenant-1"),
|
||||
account=SimpleNamespace(current_tenant_id="tenant-1"),
|
||||
import_mode="yaml-url",
|
||||
yaml_url="https://example.com/app.yaml",
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
def test_system_feature_model_defaults_enable_learn_app():
|
||||
assert SystemFeatureModel().enable_learn_app is True
|
||||
assert SystemFeatureModel().enable_step_by_step_tour is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
@ -16,14 +15,3 @@ def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPa
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_learn_app is enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_get_system_features_reads_enable_step_by_step_tour(
|
||||
monkeypatch: pytest.MonkeyPatch, enabled: bool
|
||||
) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
|
||||
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_step_by_step_tour is enabled
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from services import step_by_step_tour_service as service_module
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, state: AccountStepByStepTourState | None) -> None:
|
||||
self._state = state
|
||||
|
||||
def scalar_one_or_none(self) -> AccountStepByStepTourState | None:
|
||||
return self._state
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, state: AccountStepByStepTourState | None = None) -> None:
|
||||
self.state = state
|
||||
self.added: list[AccountStepByStepTourState] = []
|
||||
self.commit_count = 0
|
||||
self.flush_count = 0
|
||||
self.refresh_count = 0
|
||||
self.rollback_count = 0
|
||||
|
||||
def execute(self, _stmt) -> _ScalarResult:
|
||||
return _ScalarResult(self.state)
|
||||
|
||||
def add(self, state: AccountStepByStepTourState) -> None:
|
||||
self.state = state
|
||||
self.added.append(state)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
|
||||
def refresh(self, state: AccountStepByStepTourState) -> None:
|
||||
self.refresh_count += 1
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollback_count += 1
|
||||
|
||||
|
||||
class _RaceInsertSession(_FakeSession):
|
||||
def __init__(self, state_after_rollback: AccountStepByStepTourState) -> None:
|
||||
super().__init__(state=None)
|
||||
self.state_after_rollback = state_after_rollback
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
raise IntegrityError("insert", {}, Exception("duplicate"))
|
||||
|
||||
def rollback(self) -> None:
|
||||
super().rollback()
|
||||
self.state = self.state_after_rollback
|
||||
|
||||
|
||||
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
account.initialized_at = initialized_at
|
||||
account.created_at = created_at or datetime(2026, 6, 28)
|
||||
return account
|
||||
|
||||
|
||||
def _state() -> AccountStepByStepTourState:
|
||||
state = AccountStepByStepTourState(account_id="account-1")
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
return state
|
||||
|
||||
|
||||
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
|
||||
monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
|
||||
monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at)
|
||||
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["eligible"] is True
|
||||
assert result["first_workspace_id"] == "workspace-1"
|
||||
assert result["completed_task_ids"] == []
|
||||
assert len(session.added) == 1
|
||||
assert session.added[0].account_id == "account-1"
|
||||
assert session.commit_count == 1
|
||||
assert session.refresh_count == 1
|
||||
|
||||
|
||||
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
|
||||
|
||||
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 5, 31)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"eligible": False,
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
assert session.added == []
|
||||
assert session.commit_count == 0
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_account_is_not_eligible(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["eligible"] is False
|
||||
assert result["skipped"] is False
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert len(session.added) == 1
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "skip"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["eligible"] is False
|
||||
assert result["skipped"] is True
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert session.added == []
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "disable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_complete_and_uncomplete_task(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.completed_task_ids = ["home"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "complete_task", "task_id": "studio"},
|
||||
session=session,
|
||||
)
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "uncomplete_task", "task_id": "home"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["completed_task_ids"] == ["studio"]
|
||||
|
||||
|
||||
def test_patch_state_recovers_when_concurrent_request_created_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
existing_state = _state()
|
||||
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
|
||||
session = _RaceInsertSession(state_after_rollback=existing_state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
|
||||
assert session.flush_count == 1
|
||||
assert session.rollback_count == 1
|
||||
assert session.commit_count == 1
|
||||
@ -2759,14 +2759,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/components/list.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/documents/components/rename-modal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
@ -3113,27 +3105,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/hit-testing/index.tsx": {
|
||||
"react/unsupported-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/list/dataset-card/components/operations-dropdown.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/datasets/metadata/edit-metadata-batch/add-row.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zGetOnboardingStepByStepTourStateResponse,
|
||||
zPatchOnboardingStepByStepTourStateBody,
|
||||
zPatchOnboardingStepByStepTourStateResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Get account-level Step-by-step Tour state
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
description: 'Get account-level Step-by-step Tour state',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getOnboardingStepByStepTourState',
|
||||
path: '/onboarding/step-by-step-tour/state',
|
||||
tags: ['console'],
|
||||
})
|
||||
.output(zGetOnboardingStepByStepTourStateResponse)
|
||||
|
||||
/**
|
||||
* Update account-level Step-by-step Tour state
|
||||
*/
|
||||
export const patch = oc
|
||||
.route({
|
||||
description: 'Update account-level Step-by-step Tour state',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchOnboardingStepByStepTourState',
|
||||
path: '/onboarding/step-by-step-tour/state',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPatchOnboardingStepByStepTourStateBody }))
|
||||
.output(zPatchOnboardingStepByStepTourStateResponse)
|
||||
|
||||
export const state = {
|
||||
get,
|
||||
patch,
|
||||
}
|
||||
|
||||
export const stepByStepTour = {
|
||||
state,
|
||||
}
|
||||
|
||||
export const onboarding = {
|
||||
stepByStepTour,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
onboarding,
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type StepByStepTourStateResponse = {
|
||||
completed_task_ids?: Array<'home' | 'integration' | 'knowledge' | 'studio'>
|
||||
eligible: boolean
|
||||
first_workspace_id?: string | null
|
||||
manually_disabled_workspace_ids?: Array<string>
|
||||
manually_enabled_workspace_ids?: Array<string>
|
||||
skipped?: boolean
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export type StepByStepTourStatePatchPayload = {
|
||||
action:
|
||||
| 'complete_task'
|
||||
| 'disable_current_workspace'
|
||||
| 'enable_current_workspace'
|
||||
| 'skip'
|
||||
| 'uncomplete_task'
|
||||
task_id?: 'home' | 'integration' | 'knowledge' | 'studio' | null
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/onboarding/step-by-step-tour/state'
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateResponses = {
|
||||
200: StepByStepTourStateResponse
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateResponse
|
||||
= GetOnboardingStepByStepTourStateResponses[keyof GetOnboardingStepByStepTourStateResponses]
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateData = {
|
||||
body: StepByStepTourStatePatchPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/onboarding/step-by-step-tour/state'
|
||||
}
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateResponses = {
|
||||
200: StepByStepTourStateResponse
|
||||
}
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateResponse
|
||||
= PatchOnboardingStepByStepTourStateResponses[keyof PatchOnboardingStepByStepTourStateResponses]
|
||||
@ -1,42 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* StepByStepTourStateResponse
|
||||
*/
|
||||
export const zStepByStepTourStateResponse = z.object({
|
||||
completed_task_ids: z.array(z.enum(['home', 'integration', 'knowledge', 'studio'])).optional(),
|
||||
eligible: z.boolean(),
|
||||
first_workspace_id: z.string().nullish(),
|
||||
manually_disabled_workspace_ids: z.array(z.string()).optional(),
|
||||
manually_enabled_workspace_ids: z.array(z.string()).optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
updated_at: z.iso.datetime().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* StepByStepTourStatePatchPayload
|
||||
*/
|
||||
export const zStepByStepTourStatePatchPayload = z.object({
|
||||
action: z.enum([
|
||||
'complete_task',
|
||||
'disable_current_workspace',
|
||||
'enable_current_workspace',
|
||||
'skip',
|
||||
'uncomplete_task',
|
||||
]),
|
||||
task_id: z.enum(['home', 'integration', 'knowledge', 'studio']).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse
|
||||
|
||||
export const zPatchOnboardingStepByStepTourStateBody = zStepByStepTourStatePatchPayload
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPatchOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse
|
||||
@ -47,7 +47,6 @@ export const contractLoaders = {
|
||||
import('./notification/orpc.gen').then(({ notification }) => ({ notification })),
|
||||
notion: () => import('./notion/orpc.gen').then(({ notion }) => ({ notion })),
|
||||
oauth: () => import('./oauth/orpc.gen').then(({ oauth }) => ({ oauth })),
|
||||
onboarding: () => import('./onboarding/orpc.gen').then(({ onboarding }) => ({ onboarding })),
|
||||
rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })),
|
||||
refreshToken: () =>
|
||||
import('./refresh-token/orpc.gen').then(({ refreshToken }) => ({ refreshToken })),
|
||||
|
||||
@ -31,7 +31,6 @@ import { logout } from './logout/orpc.gen'
|
||||
import { notification } from './notification/orpc.gen'
|
||||
import { notion } from './notion/orpc.gen'
|
||||
import { oauth } from './oauth/orpc.gen'
|
||||
import { onboarding } from './onboarding/orpc.gen'
|
||||
import { rag } from './rag/orpc.gen'
|
||||
import { refreshToken } from './refresh-token/orpc.gen'
|
||||
import { remoteFiles } from './remote-files/orpc.gen'
|
||||
@ -83,7 +82,6 @@ const communityContract = {
|
||||
notification,
|
||||
notion,
|
||||
oauth,
|
||||
onboarding,
|
||||
rag,
|
||||
refreshToken,
|
||||
remoteFiles,
|
||||
|
||||
@ -16,7 +16,6 @@ export type SystemFeatureModel = {
|
||||
enable_learn_app: boolean
|
||||
enable_marketplace: boolean
|
||||
enable_social_oauth_login: boolean
|
||||
enable_step_by_step_tour: boolean
|
||||
enable_trial_app: boolean
|
||||
is_allow_create_workspace: boolean
|
||||
is_allow_register: boolean
|
||||
|
||||
@ -108,7 +108,6 @@ export const zSystemFeatureModel = z.object({
|
||||
enable_learn_app: z.boolean().default(true),
|
||||
enable_marketplace: z.boolean().default(false),
|
||||
enable_social_oauth_login: z.boolean().default(false),
|
||||
enable_step_by_step_tour: z.boolean().default(false),
|
||||
enable_trial_app: z.boolean().default(false),
|
||||
is_allow_create_workspace: z.boolean().default(false),
|
||||
is_allow_register: z.boolean().default(false),
|
||||
|
||||
@ -564,7 +564,6 @@ export type SystemFeatureModel = {
|
||||
enable_learn_app: boolean
|
||||
enable_marketplace: boolean
|
||||
enable_social_oauth_login: boolean
|
||||
enable_step_by_step_tour: boolean
|
||||
enable_trial_app: boolean
|
||||
is_allow_create_workspace: boolean
|
||||
is_allow_register: boolean
|
||||
|
||||
@ -851,7 +851,6 @@ export const zSystemFeatureModel = z.object({
|
||||
enable_learn_app: z.boolean().default(true),
|
||||
enable_marketplace: z.boolean().default(false),
|
||||
enable_social_oauth_login: z.boolean().default(false),
|
||||
enable_step_by_step_tour: z.boolean().default(false),
|
||||
enable_trial_app: z.boolean().default(false),
|
||||
is_allow_create_workspace: z.boolean().default(false),
|
||||
is_allow_register: z.boolean().default(false),
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 282 B |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"lastModified": 1782943615,
|
||||
"lastModified": 1782215796,
|
||||
"icons": {
|
||||
"agent-building-blocks": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"lastModified": 1782516559,
|
||||
"lastModified": 1782365697,
|
||||
"icons": {
|
||||
"agent-v2-access-point": {
|
||||
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
|
||||
@ -451,9 +451,6 @@
|
||||
"width": 12,
|
||||
"height": 12
|
||||
},
|
||||
"line-education-lesson-open-01": {
|
||||
"body": "<g fill=\"none\"><path d=\"M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667\" stroke=\"currentColor\" stroke-width=\"1.33333\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
|
||||
},
|
||||
"line-files-copy": {
|
||||
"body": "<g fill=\"none\"><path d=\"M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 331,
|
||||
"total": 330,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
@ -31,8 +31,32 @@ vi.mock('@/context/app-context', () => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [],
|
||||
}),
|
||||
useSelector: (selector: (state: {
|
||||
isCurrentWorkspaceDatasetOperator: boolean
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
isLoadingWorkspacePermissionKeys: boolean
|
||||
userProfile: { id: string }
|
||||
workspacePermissionKeys: string[]
|
||||
}) => unknown) => selector({
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isLoadingWorkspacePermissionKeys: false,
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [],
|
||||
}), () => ({
|
||||
isRbacEnabled: mockIsRbacEnabled,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: undefined,
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetRbacEnabled,
|
||||
useDatasetWorkspaceAccess,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail } from '@/service/knowledge/use-dataset'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
children: React.ReactNode
|
||||
@ -58,23 +60,15 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const {
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
userProfile,
|
||||
workspacePermissionKeys,
|
||||
} = useAppContext()
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
} = useDatasetWorkspaceAccess()
|
||||
const isRbacEnabled = useDatasetRbacEnabled()
|
||||
|
||||
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
|
||||
const shouldRedirect = shouldRedirectToDatasetList(error)
|
||||
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(datasetRes?.permission_keys, {
|
||||
currentUserId: userProfile?.id,
|
||||
resourceMaintainer: datasetRes?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}), [datasetRes?.maintainer, datasetRes?.permission_keys, isRbacEnabled, userProfile?.id, workspacePermissionKeys])
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(datasetRes, { isRbacEnabled })
|
||||
const isAccessConfigPath = pathname.endsWith('/access-config')
|
||||
const isHitTestingPath = pathname.endsWith('/hitTesting')
|
||||
const isPermissionControlledPath = isAccessConfigPath || isHitTestingPath
|
||||
|
||||
@ -20,6 +20,12 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: AppContextMock) => unknown) => selector(mockUseAppContext()),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockUseAppContext())
|
||||
})
|
||||
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
ExternalApiPanelProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
|
||||
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
const isDatasetCreatePath = (pathname: string) => {
|
||||
return pathname === '/datasets/create'
|
||||
@ -21,15 +20,14 @@ const isDatasetExternalConnectPath = (pathname: string) => {
|
||||
}
|
||||
|
||||
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
|
||||
const currentWorkspaceId = useAppContextSelector(state => state.currentWorkspace.id)
|
||||
const isLoadingCurrentWorkspace = useAppContextSelector(state => state.isLoadingCurrentWorkspace)
|
||||
const isLoadingWorkspacePermissionKeys = useAppContextSelector(state => state.isLoadingWorkspacePermissionKeys)
|
||||
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
|
||||
const {
|
||||
currentWorkspaceId,
|
||||
isLoadingAccess,
|
||||
canCreateDataset,
|
||||
canConnectExternalDataset,
|
||||
} = useDatasetWorkspaceAccess()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const isLoadingAccess = isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys
|
||||
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const shouldRedirectToDatasets = !isLoadingAccess
|
||||
&& !!currentWorkspaceId
|
||||
&& ((isDatasetCreatePath(pathname) && !canCreateDataset)
|
||||
|
||||
@ -3,7 +3,6 @@ import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import * as appsService from '@/service/apps'
|
||||
import * as exploreService from '@/service/explore'
|
||||
@ -277,24 +276,18 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
|
||||
children,
|
||||
className,
|
||||
popupClassName,
|
||||
popupProps,
|
||||
positionerProps,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="dropdown-menu-positioner" {...positionerProps}>
|
||||
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')} {...popupProps}>
|
||||
{children}
|
||||
</div>
|
||||
<div data-testid="dropdown-menu-content" role="menu" className={[className, popupClassName].filter(Boolean).join(' ')}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@ -1743,55 +1736,6 @@ describe('AppCard', () => {
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the tour-controlled operations menu as presentation only', async () => {
|
||||
render(
|
||||
<AppCard
|
||||
app={mockApp}
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
|
||||
stepByStepTourActionMenuOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'app.editApp', hidden: true })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu-positioner'))
|
||||
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu)
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should keep the tour-controlled operations menu open when its trigger is clicked', async () => {
|
||||
render(
|
||||
<AppCard
|
||||
app={mockApp}
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
|
||||
stepByStepTourActionMenuOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('dropdown-menu-trigger'))
|
||||
|
||||
expect(screen.getByText('app.editApp')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should keep tour-controlled operations menu items from running actions', async () => {
|
||||
render(
|
||||
<AppCard
|
||||
app={mockApp}
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu}
|
||||
stepByStepTourActionMenuOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete', hidden: true }))
|
||||
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Open in Explore - No App Found', () => {
|
||||
|
||||
@ -30,14 +30,6 @@ describe('Empty', () => {
|
||||
render(<Empty message="app.newApp.noAppsFound" />)
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose the step-by-step tour target on the centered empty content', () => {
|
||||
render(<Empty message={defaultMessage} stepByStepTourTarget="studio-empty-target" />)
|
||||
|
||||
const target = screen.getByText(defaultMessage).closest('[data-step-by-step-tour-target]')
|
||||
expect(target).toHaveAttribute('data-step-by-step-tour-target', 'studio-empty-target')
|
||||
expect(target).toHaveClass('flex', 'flex-col', 'items-center', 'justify-center', 'gap-3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Styling', () => {
|
||||
|
||||
@ -1,10 +1,8 @@
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { StepByStepTourAccountState } from '@/app/components/step-by-step-tour/types'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
@ -30,41 +28,6 @@ const mockAppStarredListQueryOptions = vi.hoisted(() => vi.fn((options: unknown)
|
||||
const mockUseWorkflowOnlineUsers = vi.hoisted(() => vi.fn((_options: unknown) => ({
|
||||
onlineUsersMap: {},
|
||||
})))
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const createState = (
|
||||
overrides: Partial<StepByStepTourAccountState> = {},
|
||||
): StepByStepTourAccountState => ({
|
||||
activeGuideGroup: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideIndexes: undefined,
|
||||
activeTaskId: undefined,
|
||||
completedTaskIds: ['home'],
|
||||
eligible: true,
|
||||
firstWorkspaceId: 'workspace-1',
|
||||
manuallyDisabledWorkspaceIds: [],
|
||||
manuallyEnabledWorkspaceIds: ['workspace-1'],
|
||||
minimized: true,
|
||||
skipped: false,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
})
|
||||
let state = createState()
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
reset() {
|
||||
state = createState()
|
||||
},
|
||||
setState(nextState: StepByStepTourAccountState) {
|
||||
state = nextState
|
||||
},
|
||||
setTestState(overrides: Partial<StepByStepTourAccountState> = {}) {
|
||||
state = createState(overrides)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockRouter = { replace: mockReplace }
|
||||
@ -105,13 +68,6 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/step-by-step-tour/storage', () => ({
|
||||
useSetStepByStepTourAccountState: () => (nextState: StepByStepTourAccountState) => {
|
||||
mockStepByStepTour.setState(nextState)
|
||||
},
|
||||
useStepByStepTourAccountStateValue: () => mockStepByStepTour.state,
|
||||
}))
|
||||
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
let mockWorkspacePermissionKeys = ['app.create_and_management']
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
@ -325,43 +281,10 @@ vi.mock('@/next/dynamic', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../app-card', () => ({
|
||||
AppCard: ({
|
||||
app,
|
||||
stepByStepTourActionMenuOpen,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
}: {
|
||||
app: { id: string, name: string }
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
}) => {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': `app-card-${app.id}`,
|
||||
'data-step-by-step-tour-target': stepByStepTourCardTarget,
|
||||
'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart,
|
||||
'role': 'article',
|
||||
},
|
||||
app.name,
|
||||
React.createElement('button', {
|
||||
'data-testid': `app-card-action-bar-${app.id}`,
|
||||
'data-step-by-step-tour-highlight-part': stepByStepTourActionMenuHighlightPart,
|
||||
'data-step-by-step-tour-menu-open': String(Boolean(stepByStepTourActionMenuOpen)),
|
||||
'type': 'button',
|
||||
}),
|
||||
)
|
||||
AppCard: ({ app }: { app: { id: string, name: string } }) => {
|
||||
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
|
||||
},
|
||||
AppCardActionBar: ({
|
||||
app,
|
||||
onRefresh,
|
||||
}: {
|
||||
app: { id: string }
|
||||
onRefresh?: () => void
|
||||
}) => {
|
||||
AppCardActionBar: ({ app, onRefresh }: { app: { id: string }, onRefresh?: () => void }) => {
|
||||
return React.createElement('button', {
|
||||
'data-testid': `app-card-action-bar-${app.id}`,
|
||||
'type': 'button',
|
||||
@ -374,12 +297,8 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => {
|
||||
return React.createElement('div', {
|
||||
'data-testid': 'empty-state',
|
||||
'data-step-by-step-tour-target': stepByStepTourTarget,
|
||||
'role': 'status',
|
||||
}, 'No apps found')
|
||||
default: () => {
|
||||
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
|
||||
},
|
||||
}))
|
||||
|
||||
@ -442,22 +361,9 @@ const openAppSortSelect = async (user = userEvent.setup()) => {
|
||||
return user
|
||||
}
|
||||
|
||||
const setActiveStudioStepByStepTour = (
|
||||
activeGuideIndex: number,
|
||||
activeGuideGroup: 'studioWithApps' | 'studioNoCreateEmpty' | 'studioNoCreateWithApps' | undefined = 'studioWithApps',
|
||||
) => {
|
||||
mockStepByStepTour.setTestState({
|
||||
activeTaskId: 'studio',
|
||||
activeGuideGroup,
|
||||
activeGuideIndex,
|
||||
minimized: true,
|
||||
})
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStepByStepTour.reset()
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockWorkspacePermissionKeys = ['app.create_and_management']
|
||||
mockDragging = false
|
||||
@ -523,52 +429,6 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the create menu for the Studio with-apps tour create guide', async () => {
|
||||
setActiveStudioStepByStepTour(0)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate)
|
||||
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
|
||||
expect(screen.getByText(/app\.importDSL/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank', hidden: true })).toBeInTheDocument()
|
||||
const createMenuHighlightPart = document.body.querySelector('[data-step-by-step-tour-highlight-part]')
|
||||
expect(createMenuHighlightPart)
|
||||
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu)
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should allow closing the tour-opened Studio create menu from its trigger', async () => {
|
||||
setActiveStudioStepByStepTour(0)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.newApp.startFromBlank')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the create menu before the Studio with-apps guide group is persisted', async () => {
|
||||
setActiveStudioStepByStepTour(0, undefined)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate)
|
||||
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank', hidden: true })).toBeInTheDocument()
|
||||
const createMenuHighlightPart = document.body.querySelector('[data-step-by-step-tour-highlight-part]')
|
||||
expect(createMenuHighlightPart)
|
||||
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu)
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should render filters and search before the right aligned actions', () => {
|
||||
renderList()
|
||||
|
||||
@ -652,115 +512,6 @@ describe('List', () => {
|
||||
expect(mockRefetchStarredAppList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should expose the first workspace app card and open its action menu for the Studio with-apps tour manage guide', () => {
|
||||
setActiveStudioStepByStepTour(1)
|
||||
mockStarredAppData = {
|
||||
data: [{
|
||||
id: 'starred-app-1',
|
||||
name: 'Starred App',
|
||||
description: 'Starred description',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '⭐',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
tags: [],
|
||||
author_name: 'Author 1',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
has_more: false,
|
||||
}
|
||||
|
||||
renderList()
|
||||
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
const starredCard = screen.getByRole('link', { name: /Starred App/ })
|
||||
const starredActionBar = screen.getByTestId('app-card-action-bar-starred-app-1')
|
||||
|
||||
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard)
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu)
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'true')
|
||||
expect(screen.queryByRole('menuitem', { name: 'app.newApp.startFromBlank' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: 'app.newApp.startFromTemplate' })).not.toBeInTheDocument()
|
||||
expect(starredCard).not.toHaveAttribute('data-step-by-step-tour-target')
|
||||
expect(starredActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
})
|
||||
|
||||
it('should highlight the first starred app row for the Studio no-create with-apps tour', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
|
||||
mockStarredAppData = {
|
||||
data: [{
|
||||
id: 'starred-app-1',
|
||||
name: 'Starred App',
|
||||
description: 'Starred description',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '⭐',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
tags: [],
|
||||
author_name: 'Author 1',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
}],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
has_more: false,
|
||||
}
|
||||
|
||||
renderList()
|
||||
|
||||
const starredCard = screen.getByRole('link', { name: /Starred App/ })
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
|
||||
expect(starredCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard)
|
||||
expect(starredCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
|
||||
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-target')
|
||||
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
|
||||
})
|
||||
|
||||
it('should highlight the first all-apps row for the Studio no-create with-apps tour when there are no starred apps', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
|
||||
|
||||
renderList()
|
||||
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const secondWorkspaceCard = screen.getByTestId('app-card-app-2')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
|
||||
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard)
|
||||
expect(firstWorkspaceCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
|
||||
expect(secondWorkspaceCard).toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard)
|
||||
expect(firstWorkspaceActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
|
||||
})
|
||||
|
||||
it('should expose the regular empty state for the Studio no-create empty tour', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateEmpty')
|
||||
|
||||
renderList()
|
||||
|
||||
const target = document.querySelector(getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty))
|
||||
|
||||
expect(target).toBeInTheDocument()
|
||||
expect(target).toBe(screen.getByTestId('empty-state'))
|
||||
expect(target).not.toHaveClass('absolute', 'top-1/2', 'left-1/2')
|
||||
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render new app card in the app grid', () => {
|
||||
renderList()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
@ -782,14 +533,6 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /app\.newApp\.startFromTemplate/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate)
|
||||
expect(screen.getByRole('button', { name: /app\.newApp\.startFromBlank/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank)
|
||||
expect(screen.getByRole('button', { name: /app\.importDSL/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL)
|
||||
expect(screen.getByText('app.firstEmpty.learnDifyTitle').closest('[data-step-by-step-tour-target]'))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify)
|
||||
})
|
||||
|
||||
it('should lay out first empty state placeholder cards with auto-fill grid columns', () => {
|
||||
|
||||
@ -39,7 +39,6 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import StarIcon from '@/app/components/base/icons/src/vender/Star'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@ -101,10 +100,6 @@ type AppCardProps = {
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
onRefresh?: () => void
|
||||
onOpenTagManagement?: () => void
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
type AppAccessModeIconProps = {
|
||||
@ -313,10 +308,7 @@ type AppCardActionBarProps = {
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
export function AppCardActionBar({
|
||||
app,
|
||||
onRefresh,
|
||||
}: AppCardActionBarProps) {
|
||||
export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
|
||||
@ -749,16 +741,7 @@ export function AppCardActionBar({
|
||||
)
|
||||
}
|
||||
|
||||
export function AppCard({
|
||||
app,
|
||||
onlineUsers = [],
|
||||
onRefresh,
|
||||
onOpenTagManagement = () => {},
|
||||
stepByStepTourActionMenuOpen = false,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
}: AppCardProps) {
|
||||
export function AppCard({ app, onlineUsers = [], onRefresh, onOpenTagManagement = () => {} }: AppCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
|
||||
@ -773,13 +756,7 @@ export function AppCard({
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const operationsMenu = useStepByStepTourControlledDropdown({
|
||||
allowTriggerCloseWhileControlled: false,
|
||||
controlledOpen: stepByStepTourActionMenuOpen,
|
||||
})
|
||||
const closeOperationsMenu = operationsMenu.close
|
||||
const isOperationsMenuOpen = operationsMenu.open
|
||||
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
|
||||
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
|
||||
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
|
||||
@ -830,44 +807,44 @@ export function AppCard({
|
||||
}
|
||||
|
||||
function handleShowEditModal() {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowEditModal(true)
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowDuplicateModal() {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowDuplicateModal(true)
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowSwitchModal() {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowSwitchModal(true)
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowDeleteConfirm() {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowConfirmDelete(true)
|
||||
})
|
||||
}
|
||||
|
||||
function handleShowAccessControl() {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
queueMicrotask(() => {
|
||||
setShowAccessControl(true)
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenAccessConfig = useCallback(() => {
|
||||
closeOperationsMenu()
|
||||
setIsOperationsMenuOpen(false)
|
||||
push(`/app/${app.id}/access-config`)
|
||||
}, [app.id, closeOperationsMenu, push])
|
||||
}, [app.id, push])
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = async ({
|
||||
name,
|
||||
@ -1053,7 +1030,6 @@ export function AppCard({
|
||||
const starActionLabel = app.is_starred
|
||||
? t('studio.unstarApp', { ns: 'app' })
|
||||
: t('studio.starApp', { ns: 'app' })
|
||||
const operationsMenuOpen = isOperationsMenuOpen
|
||||
const showPreviewOnlyAccessWarning = useCallback(() => {
|
||||
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
|
||||
}, [t])
|
||||
@ -1124,28 +1100,24 @@ export function AppCard({
|
||||
>
|
||||
{isPreviewOnly
|
||||
? (
|
||||
<div
|
||||
<article
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled="true"
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
>
|
||||
{appCardContent}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
href={appHref}
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
>
|
||||
{appCardContent}
|
||||
@ -1153,6 +1125,10 @@ export function AppCard({
|
||||
)}
|
||||
<div
|
||||
className="absolute top-[104px] right-3 left-3 flex h-[26px] min-w-0 items-start"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<AppCardTags
|
||||
appId={app.id}
|
||||
@ -1167,7 +1143,7 @@ export function AppCard({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 right-2 flex items-center overflow-hidden rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-xs transition-opacity',
|
||||
operationsMenuOpen
|
||||
isOperationsMenuOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100',
|
||||
)}
|
||||
@ -1195,12 +1171,12 @@ export function AppCard({
|
||||
<TooltipContent>{starActionLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
{shouldShowOperationsMenu && (
|
||||
<DropdownMenu modal={false} open={operationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenu modal={false} open={isOperationsMenuOpen} onOpenChange={setIsOperationsMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('operation.more', { ns: 'common' })}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
operationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@ -1213,12 +1189,7 @@ export function AppCard({
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
disableMotion: operationsMenu.controlled,
|
||||
highlightPart: operationsMenu.controlled ? stepByStepTourActionMenuHighlightPart : undefined,
|
||||
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
|
||||
popupClassName: operationsMenuWidthClassName,
|
||||
})}
|
||||
popupClassName={operationsMenuWidthClassName}
|
||||
>
|
||||
{systemFeatures.webapp_auth.enabled
|
||||
? (
|
||||
|
||||
@ -7,7 +7,6 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import Link from '@/next/link'
|
||||
import { AppSortFilter } from './app-sort-filter'
|
||||
@ -33,9 +32,6 @@ type AppListHeaderFiltersProps = {
|
||||
onImportDSL: () => void
|
||||
onOpenTagManagement: () => void
|
||||
showCreateButton: boolean
|
||||
stepByStepTourCreateMenuOpen?: boolean
|
||||
stepByStepTourCreateMenuTarget?: string
|
||||
stepByStepTourCreateMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
export function AppListHeaderFilters({
|
||||
@ -54,14 +50,8 @@ export function AppListHeaderFilters({
|
||||
onImportDSL,
|
||||
onOpenTagManagement,
|
||||
showCreateButton,
|
||||
stepByStepTourCreateMenuOpen,
|
||||
stepByStepTourCreateMenuTarget,
|
||||
stepByStepTourCreateMenuHighlightPart,
|
||||
}: AppListHeaderFiltersProps) {
|
||||
const { t } = useTranslation()
|
||||
const createMenu = useStepByStepTourControlledDropdown({
|
||||
controlledOpen: stepByStepTourCreateMenuOpen,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
@ -93,11 +83,10 @@ export function AppListHeaderFilters({
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
{showCreateButton && (
|
||||
<DropdownMenu modal={false} open={createMenu.open} onOpenChange={createMenu.onOpenChange}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
data-step-by-step-tour-target={stepByStepTourCreateMenuTarget}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
|
||||
@ -111,12 +100,7 @@ export function AppListHeaderFilters({
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
disableMotion: createMenu.controlled,
|
||||
highlightPart: createMenu.controlled ? stepByStepTourCreateMenuHighlightPart : undefined,
|
||||
interactionMode: createMenu.controlled ? 'presentation' : 'interactive',
|
||||
popupClassName: 'w-70 p-0',
|
||||
})}
|
||||
popupClassName="w-70 p-0"
|
||||
>
|
||||
<div className="py-1">
|
||||
<DropdownMenuItem
|
||||
|
||||
@ -4,18 +4,12 @@ import FilterEmptyState from '@/app/components/base/filter-empty-state'
|
||||
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
stepByStepTourTarget?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message, stepByStepTourTarget }: EmptyProps) => {
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FilterEmptyState
|
||||
title={message ?? t('filterEmpty.noApps', { ns: 'app' })}
|
||||
contentDataAttributes={stepByStepTourTarget ? { 'data-step-by-step-tour-target': stepByStepTourTarget } : undefined}
|
||||
/>
|
||||
)
|
||||
return <FilterEmptyState title={message ?? t('filterEmpty.noApps', { ns: 'app' })} />
|
||||
}
|
||||
|
||||
export default React.memo(Empty)
|
||||
|
||||
@ -8,7 +8,6 @@ type VisualStyle = 'default' | 'compact' | 'list'
|
||||
type BaseProps = {
|
||||
badge?: string
|
||||
badgeVariant?: 'basic' | 'advanced'
|
||||
stepByStepTourTarget?: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
title: string
|
||||
@ -128,7 +127,7 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
|
||||
|
||||
if (props.href) {
|
||||
return (
|
||||
<Link href={props.href} className={className} data-step-by-step-tour-target={props.stepByStepTourTarget}>
|
||||
<Link href={props.href} className={className}>
|
||||
<ActionCardContent
|
||||
badge={props.badge}
|
||||
badgeVariant={props.badgeVariant}
|
||||
@ -142,7 +141,7 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} data-step-by-step-tour-target={props.stepByStepTourTarget} onClick={props.onClick}>
|
||||
<button type="button" className={className} onClick={props.onClick}>
|
||||
<ActionCardContent
|
||||
badge={props.badge}
|
||||
badgeVariant={props.badgeVariant}
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import LearnDify from '@/app/components/explore/learn-dify'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import FirstEmptyActionCard from './action-card'
|
||||
|
||||
const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `placeholder-card-${index}`)
|
||||
@ -14,7 +13,6 @@ type EmptyCreateAction = {
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
target: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@ -39,7 +37,6 @@ function FirstEmptyState({
|
||||
title: t('newApp.startFromTemplate', { ns: 'app' }),
|
||||
description: t('firstEmpty.templateDescription', { ns: 'app' }),
|
||||
onClick: onCreateTemplate,
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate,
|
||||
},
|
||||
{
|
||||
id: 'blank',
|
||||
@ -47,7 +44,6 @@ function FirstEmptyState({
|
||||
title: t('newApp.startFromBlank', { ns: 'app' }),
|
||||
description: t('firstEmpty.blankDescription', { ns: 'app' }),
|
||||
onClick: onCreateBlank,
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank,
|
||||
},
|
||||
{
|
||||
id: 'dsl',
|
||||
@ -55,7 +51,6 @@ function FirstEmptyState({
|
||||
title: t('importDSL', { ns: 'app' }),
|
||||
description: t('firstEmpty.importDescription', { ns: 'app' }),
|
||||
onClick: onImportDSL,
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL,
|
||||
},
|
||||
]
|
||||
|
||||
@ -88,7 +83,6 @@ function FirstEmptyState({
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
onClick={action.onClick}
|
||||
stepByStepTourTarget={action.target}
|
||||
title={action.title}
|
||||
visualStyle="list"
|
||||
/>
|
||||
@ -103,7 +97,6 @@ function FirstEmptyState({
|
||||
description={actions[2]!.description}
|
||||
icon={actions[2]!.icon}
|
||||
onClick={actions[2]!.onClick}
|
||||
stepByStepTourTarget={actions[2]!.target}
|
||||
title={actions[2]!.title}
|
||||
visualStyle="list"
|
||||
/>
|
||||
@ -112,15 +105,13 @@ function FirstEmptyState({
|
||||
</section>
|
||||
</div>
|
||||
{showLearnDify && (
|
||||
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify}>
|
||||
<LearnDify
|
||||
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
|
||||
dismissible={false}
|
||||
itemLimit={4}
|
||||
showDescription
|
||||
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
|
||||
/>
|
||||
</div>
|
||||
<LearnDify
|
||||
className="px-4 pt-2 pb-0 [&_div.grid]:gap-3 [&>div]:mx-0 [&>div]:rounded-t-2xl [&>div]:rounded-b-none [&>div]:px-5 [&>div]:pt-4 [&>div]:pb-5"
|
||||
dismissible={false}
|
||||
itemLimit={4}
|
||||
showDescription
|
||||
title={t('firstEmpty.learnDifyTitle', { ns: 'app' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -7,14 +7,6 @@ import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNeedRefreshAppList } from '@/app/components/apps/storage'
|
||||
import {
|
||||
useSetStepByStepTourAccountState,
|
||||
useStepByStepTourAccountStateValue,
|
||||
} from '@/app/components/step-by-step-tour/storage'
|
||||
import {
|
||||
getStepByStepTourGuides,
|
||||
STEP_BY_STEP_TOUR_TARGETS,
|
||||
} from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@ -38,7 +30,6 @@ import { StarredAppList } from './starred-app-list'
|
||||
import { StudioListHeader } from './studio-list-header'
|
||||
|
||||
const STARRED_APP_LIMIT = 100
|
||||
const STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT = 4
|
||||
|
||||
type AppListQuery = NonNullable<GetAppsData['query']>
|
||||
type AppListSortBy = NonNullable<AppListQuery['sort_by']>
|
||||
@ -72,10 +63,6 @@ function List({
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const [needsRefreshAppList, setNeedsRefreshAppList] = useNeedRefreshAppList()
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
|
||||
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
@ -219,24 +206,6 @@ function List({
|
||||
const hasActiveFilters = category !== 'all' || tagIDs.length > 0 || keywords.trim().length > 0 || debouncedKeywords.trim().length > 0 || creatorIDs.length > 0
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const showFirstEmptyState = !showSkeleton && !hasAnyApp && canCreateApp && hasResolvedFirstPage && !hasActiveFilters
|
||||
const showNoCreateEmptyState = !showSkeleton && !hasAnyApp && !canCreateApp && hasResolvedFirstPage && !hasActiveFilters
|
||||
const activeStudioGuideGroup = canCreateApp
|
||||
? showFirstEmptyState
|
||||
? 'studioEmpty'
|
||||
: hasAnyApp ? 'studioWithApps' : undefined
|
||||
: hasAnyApp
|
||||
? 'studioNoCreateWithApps'
|
||||
: showNoCreateEmptyState ? 'studioNoCreateEmpty' : undefined
|
||||
const effectiveActiveStudioGuideGroup = stepByStepTourAccountState.activeGuideGroup ?? activeStudioGuideGroup
|
||||
const activeStudioGuides = stepByStepTourAccountState.activeTaskId === 'studio' && effectiveActiveStudioGuideGroup
|
||||
? getStepByStepTourGuides('studio', effectiveActiveStudioGuideGroup)
|
||||
: []
|
||||
const activeStudioGuide = activeStudioGuides[stepByStepTourAccountState.activeGuideIndex ?? 0]
|
||||
const shouldOpenStepByStepTourCreateMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate
|
||||
const shouldOpenStepByStepTourAppCardActionMenu = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard
|
||||
const shouldHighlightStepByStepTourNoCreateAppRow = activeStudioGuide?.target === STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
|
||||
const shouldHighlightStepByStepTourStarredAppRow = shouldHighlightStepByStepTourNoCreateAppRow && starredApps.length > 0
|
||||
const shouldHighlightStepByStepTourAllAppsRow = shouldHighlightStepByStepTourNoCreateAppRow && !shouldHighlightStepByStepTourStarredAppRow
|
||||
const openCreateBlankModal = useCallback(() => {
|
||||
if (canCreateApp)
|
||||
setShowNewAppModal(true)
|
||||
@ -250,30 +219,6 @@ function List({
|
||||
setShowCreateFromDSLModal(true)
|
||||
}, [canCreateApp])
|
||||
|
||||
useEffect(() => {
|
||||
if (stepByStepTourAccountState.activeTaskId !== 'studio')
|
||||
return
|
||||
if (!hasResolvedFirstPage || showSkeleton || !activeStudioGuideGroup)
|
||||
return
|
||||
if (stepByStepTourAccountState.activeGuideGroup === activeStudioGuideGroup)
|
||||
return
|
||||
|
||||
// Sync the active walkthrough branch into the tour storage owner after the
|
||||
// Studio list data resolves.
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeGuideGroup: activeStudioGuideGroup,
|
||||
activeGuideIndex: 0,
|
||||
})
|
||||
}, [
|
||||
activeStudioGuideGroup,
|
||||
hasResolvedFirstPage,
|
||||
setStepByStepTourAccountState,
|
||||
showSkeleton,
|
||||
stepByStepTourAccountState,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
@ -305,9 +250,6 @@ function List({
|
||||
onImportDSL={openCreateFromDSLModal}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
showCreateButton={canCreateApp}
|
||||
stepByStepTourCreateMenuOpen={activeStudioGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
|
||||
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate}
|
||||
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu}
|
||||
/>
|
||||
</StudioListHeader>
|
||||
{showFirstEmptyState
|
||||
@ -325,9 +267,6 @@ function List({
|
||||
<StarredAppList
|
||||
apps={starredApps}
|
||||
onRefresh={refreshAppLists}
|
||||
stepByStepTourCardTarget={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard : undefined}
|
||||
stepByStepTourCardHighlightPart={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard : undefined}
|
||||
stepByStepTourHighlightedCardCount={shouldHighlightStepByStepTourStarredAppRow ? STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT : 0}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
@ -338,24 +277,16 @@ function List({
|
||||
{showSkeleton
|
||||
? <AppCardSkeleton count={6} />
|
||||
: hasAnyApp
|
||||
? apps.map((app, index) => (
|
||||
? apps.map(app => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onlineUsers={workflowOnlineUsersMap[app.id] ?? []}
|
||||
onRefresh={refreshAppLists}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
stepByStepTourActionMenuOpen={index === 0 ? shouldOpenStepByStepTourAppCardActionMenu : undefined}
|
||||
stepByStepTourCardTarget={index === 0
|
||||
? shouldHighlightStepByStepTourAllAppsRow
|
||||
? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard
|
||||
: canCreateApp ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard : undefined
|
||||
: undefined}
|
||||
stepByStepTourCardHighlightPart={index < STEP_BY_STEP_TOUR_APP_ROW_CARD_COUNT && shouldHighlightStepByStepTourAllAppsRow ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard : undefined}
|
||||
stepByStepTourActionMenuHighlightPart={index === 0 && shouldOpenStepByStepTourAppCardActionMenu ? STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu : undefined}
|
||||
/>
|
||||
))
|
||||
: <Empty stepByStepTourTarget={showNoCreateEmptyState ? STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty : undefined} />}
|
||||
: <Empty />}
|
||||
{isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
@ -20,16 +20,9 @@ import { AppCardActionBar } from './app-card'
|
||||
type StarredAppCardProps = {
|
||||
app: App
|
||||
onRefresh?: () => void
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
}
|
||||
|
||||
export function StarredAppCard({
|
||||
app,
|
||||
onRefresh,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
}: StarredAppCardProps) {
|
||||
export function StarredAppCard({ app, onRefresh }: StarredAppCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const currentUserId = useAppContextSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
|
||||
@ -97,36 +90,24 @@ export function StarredAppCard({
|
||||
<div className="group relative">
|
||||
{isPreviewOnly
|
||||
? (
|
||||
<div
|
||||
<article
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled="true"
|
||||
aria-label={app.name}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={cardClassName}
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
>
|
||||
{cardContent}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
href={href}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={cardClassName}
|
||||
>
|
||||
<Link href={href} className={cardClassName}>
|
||||
{cardContent}
|
||||
</Link>
|
||||
)}
|
||||
{!isPreviewOnly && (
|
||||
<AppCardActionBar
|
||||
app={app}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)}
|
||||
{!isPreviewOnly && <AppCardActionBar app={app} onRefresh={onRefresh} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,9 +8,6 @@ import { StarredAppCard } from './starred-app-card'
|
||||
type StarredAppListProps = {
|
||||
apps: App[]
|
||||
onRefresh?: () => void
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
stepByStepTourHighlightedCardCount?: number
|
||||
}
|
||||
|
||||
function SectionDivider({ label }: { label: string }) {
|
||||
@ -26,9 +23,6 @@ function SectionDivider({ label }: { label: string }) {
|
||||
export function StarredAppList({
|
||||
apps,
|
||||
onRefresh,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
stepByStepTourHighlightedCardCount = 0,
|
||||
}: StarredAppListProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -39,13 +33,11 @@ export function StarredAppList({
|
||||
<>
|
||||
<SectionDivider label={t('studio.starred', { ns: 'app' })} />
|
||||
<div className={APP_LIST_GRID_CLASS_NAME}>
|
||||
{apps.map((app, index) => (
|
||||
{apps.map(app => (
|
||||
<StarredAppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
onRefresh={onRefresh}
|
||||
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
|
||||
stepByStepTourCardHighlightPart={index < stepByStepTourHighlightedCardCount ? stepByStepTourCardHighlightPart : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type DataAttributes = {
|
||||
[key: `data-${string}`]: string | undefined
|
||||
}
|
||||
|
||||
type FilterEmptyStateProps = {
|
||||
title: ReactNode
|
||||
className?: string
|
||||
contentDataAttributes?: DataAttributes
|
||||
}
|
||||
|
||||
const CARD_COUNT = 16
|
||||
@ -16,7 +11,6 @@ const CARD_COUNT = 16
|
||||
const FilterEmptyState = ({
|
||||
title,
|
||||
className,
|
||||
contentDataAttributes,
|
||||
}: FilterEmptyStateProps) => {
|
||||
return (
|
||||
<div className={cn('pointer-events-none absolute inset-0 z-20 grid grid-cols-4 grid-rows-4 gap-3 px-8 pt-2', className)}>
|
||||
@ -25,7 +19,7 @@ const FilterEmptyState = ({
|
||||
))}
|
||||
<div className="absolute inset-0 bg-linear-to-b from-background-body/0 to-background-body" />
|
||||
<div className="absolute inset-0 flex items-center justify-center overflow-hidden p-2">
|
||||
<div {...contentDataAttributes} className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<div className="flex size-14 items-center justify-center rounded-lg">
|
||||
<div className="flex size-full min-w-px items-center justify-center overflow-hidden rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1">
|
||||
<span aria-hidden className="i-ri-robot-2-line size-6 text-text-tertiary" />
|
||||
|
||||
@ -79,6 +79,14 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
|
||||
isRbacEnabled: mockIsRbacEnabled,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/access-rules-editor', () => ({
|
||||
default: (props: AccessRulesEditorProps) => {
|
||||
mockAccessRulesEditor.props = props
|
||||
|
||||
@ -2,15 +2,16 @@
|
||||
|
||||
import type { ResourceOpenScope } from '@/models/access-control'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AccessRulesEditor from '@/app/components/access-rules-editor'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetRbacEnabled,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { getAccessControlTemplateLanguage } from '@/i18n-config/language'
|
||||
import {
|
||||
useDatasetAccessRules,
|
||||
@ -19,7 +20,6 @@ import {
|
||||
useUpdateDatasetOpenScope,
|
||||
useUpdateDatasetUserAccessSettings,
|
||||
} from '@/service/access-control/use-dataset-access-config'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
|
||||
type DatasetAccessConfigPageProps = {
|
||||
datasetId: string
|
||||
@ -30,16 +30,8 @@ const DatasetAccessConfigPage = ({ datasetId }: DatasetAccessConfigPageProps) =>
|
||||
const locale = useLocale()
|
||||
const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale])
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const canAccessConfig = useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}).canAccessConfig, [currentUserId, dataset?.maintainer, dataset?.permission_keys, isRbacEnabled, workspacePermissionKeys])
|
||||
const isRbacEnabled = useDatasetRbacEnabled()
|
||||
const canAccessConfig = useDatasetACLCapabilities(dataset, { isRbacEnabled }).canAccessConfig
|
||||
const { data: datasetAccessRulesResponse, isLoading: isLoadingDatasetAccessRules } = useDatasetAccessRules(datasetId, language, { enabled: canAccessConfig })
|
||||
const { data: datasetUserAccessSettingsResponse, isLoading: isLoadingDatasetUserAccessSettings } = useDatasetUserAccessSettings(datasetId, language, { enabled: canAccessConfig })
|
||||
const { mutate: updateDatasetOpenScope, isPending: isUpdatingDatasetOpenScope } = useUpdateDatasetOpenScope(datasetId)
|
||||
|
||||
@ -54,6 +54,16 @@ vi.mock('@/context/app-context', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: mockCurrentUserId },
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock modal context
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
|
||||
@ -7,16 +7,18 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetWorkspaceAccess,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useIntegrationsSetting } from '@/app/components/header/account-setting/use-integrations-setting'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useGetDefaultDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import AppUnavailable from '../../base/app-unavailable'
|
||||
import { ModelTypeEnum } from '../../header/account-setting/model-provider-page/declarations'
|
||||
import StepOne from './step-one'
|
||||
@ -43,15 +45,10 @@ const DatasetUpdateForm = ({ datasetId }: DatasetUpdateFormProps) => {
|
||||
const router = useRouter()
|
||||
const openIntegrationsSetting = useIntegrationsSetting()
|
||||
const datasetDetail = useDatasetDetailContextWithSelector(state => state.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const isLoadingWorkspacePermissionKeys = useAppContextWithSelector(state => state.isLoadingWorkspacePermissionKeys)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { isLoadingWorkspacePermissionKeys } = useDatasetWorkspaceAccess()
|
||||
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const canAddDocumentsToDataset = !datasetId || getDatasetACLCapabilities(datasetDetail?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: datasetDetail?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canUse
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(datasetDetail)
|
||||
const canAddDocumentsToDataset = !datasetId || datasetACLCapabilities.canUse
|
||||
const shouldRedirectToDocuments = !!datasetId
|
||||
&& !!datasetDetail
|
||||
&& !isLoadingWorkspacePermissionKeys
|
||||
|
||||
@ -55,6 +55,15 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: 'test-user' },
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock document service hooks
|
||||
const mockInvalidDocumentList = vi.fn()
|
||||
const mockInvalidDocumentDetail = vi.fn()
|
||||
|
||||
@ -42,6 +42,15 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata', () => ({
|
||||
default: () => ({
|
||||
isShowEditModal: false,
|
||||
|
||||
@ -9,13 +9,12 @@ import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-la
|
||||
import Operations from '@/app/components/datasets/documents/components/operations'
|
||||
import SummaryStatus from '@/app/components/datasets/documents/detail/completed/common/summary-status'
|
||||
import StatusItem from '@/app/components/datasets/documents/status-item'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import DocumentSourceIcon from './document-source-icon'
|
||||
import { renderTdValue } from './utils'
|
||||
|
||||
@ -62,13 +61,7 @@ const DocumentTableRow = React.memo(({
|
||||
const searchParams = useSearchParams()
|
||||
const documentNameId = React.useId()
|
||||
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}), [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
|
||||
|
||||
const isFile = doc.data_source_type === DataSourceType.FILE
|
||||
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
|
||||
|
||||
@ -6,12 +6,11 @@ import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
|
||||
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
|
||||
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import BatchAction from '../detail/completed/common/batch-action'
|
||||
import s from '../style.module.css'
|
||||
import { DocumentTableRow, SortHeader } from './document-list/components'
|
||||
@ -61,13 +60,7 @@ const DocumentList = ({
|
||||
const pageSize = pagination.limit ?? 10
|
||||
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
|
||||
const datasetConfig = useDatasetDetailContext(s => s.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(datasetConfig?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: datasetConfig?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}), [datasetConfig?.maintainer, datasetConfig?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(datasetConfig)
|
||||
const chunkingMode = datasetConfig?.doc_form
|
||||
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
|
||||
const isQAMode = chunkingMode === ChunkingMode.qa
|
||||
@ -141,7 +134,7 @@ const DocumentList = ({
|
||||
<thead className="h-8 border-b border-divider-subtle text-xs/8 font-medium text-text-tertiary uppercase">
|
||||
<tr>
|
||||
<td className="w-12">
|
||||
<div className="flex items-center" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center" role="presentation" onClick={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
|
||||
{embeddingAvailable && (
|
||||
<Checkbox
|
||||
className="mr-2 shrink-0"
|
||||
|
||||
@ -63,6 +63,16 @@ vi.mock('@/context/app-context', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: mockCurrentUserId },
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useCurrentPlanVectorSpace: () => ({
|
||||
data: {
|
||||
|
||||
@ -9,7 +9,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetWorkspaceAccess,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { DatasourceType } from '@/models/pipeline'
|
||||
@ -17,7 +20,6 @@ import { useRouter } from '@/next/navigation'
|
||||
import { useCurrentPlanVectorSpace } from '@/service/use-billing'
|
||||
import { useFileUploadConfig } from '@/service/use-common'
|
||||
import { usePublishedPipelineInfo } from '@/service/use-pipeline'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import { useDataSourceStore } from './data-source/store'
|
||||
import DataSourceProvider from './data-source/store/provider'
|
||||
import {
|
||||
@ -40,15 +42,9 @@ const CreateFormPipeline = () => {
|
||||
const enableBilling = useProviderContextSelector(state => state.enableBilling)
|
||||
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
|
||||
const pipelineId = dataset?.pipeline_id
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const isLoadingWorkspacePermissionKeys = useAppContextWithSelector(state => state.isLoadingWorkspacePermissionKeys)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { isLoadingWorkspacePermissionKeys } = useDatasetWorkspaceAccess()
|
||||
const dataSourceStore = useDataSourceStore()
|
||||
const canAddDocumentsToDataset = getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canUse
|
||||
const canAddDocumentsToDataset = useDatasetACLCapabilities(dataset).canUse
|
||||
const shouldRedirectToDocuments = !!dataset
|
||||
&& !isLoadingWorkspacePermissionKeys
|
||||
&& !canAddDocumentsToDataset
|
||||
|
||||
@ -10,8 +10,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import FloatRightContainer from '@/app/components/base/float-right-container'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import Metadata from '@/app/components/datasets/metadata/metadata-document'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { ChunkingMode, DisplayStatusList } from '@/models/datasets'
|
||||
@ -20,7 +20,6 @@ import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '
|
||||
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { segmentImportStatus } from '@/types/dataset'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import Operations from '../components/operations'
|
||||
import StatusItem from '../status-item'
|
||||
import BatchModal from './batch-modal'
|
||||
@ -49,17 +48,8 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const embeddingAvailable = !!dataset?.embedding_available
|
||||
const datasetACLCapabilities = useMemo(
|
||||
() => getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}),
|
||||
[dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys],
|
||||
)
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
|
||||
const canEditDocument = datasetACLCapabilities.canEdit
|
||||
const [showMetadata, setShowMetadata] = useState(!isMobile)
|
||||
const [newSegmentModalVisible, setNewSegmentModalVisible] = useState(false)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
@ -10,7 +10,6 @@ import { useRouter } from '@/next/navigation'
|
||||
import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata'
|
||||
import DocumentsHeader from './components/documents-header'
|
||||
import EmptyElement from './components/empty-element'
|
||||
@ -31,14 +30,8 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
|
||||
const dataset = useDatasetDetailContextWithSelector(s => s.dataset)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const embeddingAvailable = !!dataset?.embedding_available
|
||||
const datasetACLCapabilities = getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
})
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
|
||||
|
||||
// Use custom hook for page state management
|
||||
const {
|
||||
|
||||
@ -4,12 +4,11 @@ import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import Link from '@/next/link'
|
||||
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
|
||||
type CardProps = {
|
||||
apiEnabled: boolean
|
||||
@ -22,19 +21,10 @@ const Card = ({
|
||||
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
|
||||
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
|
||||
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
|
||||
|
||||
const datasetACLCapabilities = React.useMemo(
|
||||
() => getDatasetACLCapabilities(dataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}),
|
||||
[dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys],
|
||||
)
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
|
||||
const canManageApiAccess = datasetACLCapabilities.canEdit
|
||||
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
@ -15,6 +15,14 @@ vi.mock('@/context/app-context', () => ({
|
||||
selector({ workspacePermissionKeys: mockWorkspacePermissionKeys }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
|
||||
@ -4,9 +4,8 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import Card from './card'
|
||||
|
||||
type ServiceApiProps = {
|
||||
@ -19,8 +18,7 @@ const ServiceApi = ({
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canManageSecretKey = hasPermission(workspacePermissionKeys, 'dataset.api_key.manage')
|
||||
const { canManageDatasetApiKeys: canManageSecretKey } = useDatasetWorkspaceAccess()
|
||||
|
||||
const handleOpenSecretKeyModal = useCallback(() => {
|
||||
setIsSecretKeyModalVisible(true)
|
||||
|
||||
@ -83,6 +83,12 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState)
|
||||
})
|
||||
|
||||
const mockRecordsRefetch = vi.fn()
|
||||
const mockHitTestingMutateAsync = vi.fn()
|
||||
const mockExternalHitTestingMutateAsync = vi.fn()
|
||||
|
||||
@ -27,7 +27,7 @@ import { useContext } from 'use-context-selector'
|
||||
import FloatRightContainer from '@/app/components/base/float-right-container'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useDatasetTestingRecords } from '@/service/knowledge/use-dataset'
|
||||
@ -35,7 +35,6 @@ import {
|
||||
useExternalKnowledgeBaseHitTesting,
|
||||
useHitTesting,
|
||||
} from '@/service/knowledge/use-hit-testing'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import { CardSkelton } from '../documents/detail/completed/skeleton/general-list-skeleton'
|
||||
import EmptyRecords from './components/empty-records'
|
||||
import QueryInput from './components/query-input'
|
||||
@ -63,13 +62,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
|
||||
const [currPage, setCurrPage] = useState<number>(0)
|
||||
const { dataset: currentDataset } = useContext(DatasetDetailContext)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const canRunRetrievalRecall = React.useMemo(() => getDatasetACLCapabilities(currentDataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: currentDataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}).canRetrievalRecall, [currentDataset?.maintainer, currentDataset?.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
const canRunRetrievalRecall = useDatasetACLCapabilities(currentDataset).canRetrievalRecall
|
||||
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 }, { enabled: canRunRetrievalRecall })
|
||||
|
||||
const total = recordsRes?.total || 0
|
||||
@ -133,6 +126,21 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
if (!canRunRetrievalRecall)
|
||||
return <Loading type="app" />
|
||||
|
||||
let rightPanelContent = renderEmptyState()
|
||||
if (isRetrievalLoading) {
|
||||
rightPanelContent = (
|
||||
<div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3">
|
||||
<CardSkelton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (hitResult?.records.length) {
|
||||
rightPanelContent = renderHitResults(hitResult.records)
|
||||
}
|
||||
else if (externalHitResult?.records.length) {
|
||||
rightPanelContent = renderHitResults(externalHitResult.records)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex size-full gap-x-6 overflow-y-auto pl-6">
|
||||
<div className="flex min-w-0 flex-1 flex-col py-3">
|
||||
@ -193,23 +201,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
onClose={hideRightPanel}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col pt-3">
|
||||
{isRetrievalLoading
|
||||
? (
|
||||
<div className="flex h-full flex-col rounded-tl-2xl bg-background-body px-4 py-3">
|
||||
<CardSkelton />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
(() => {
|
||||
if (!hitResult?.records.length && !externalHitResult?.records.length)
|
||||
return renderEmptyState()
|
||||
|
||||
if (hitResult?.records.length)
|
||||
return renderHitResults(hitResult.records)
|
||||
|
||||
return renderHitResults(externalHitResult?.records || [])
|
||||
})()
|
||||
)}
|
||||
{rightPanelContent}
|
||||
</div>
|
||||
</FloatRightContainer>
|
||||
<Drawer
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import type { PermissionKey } from '@/models/access-control'
|
||||
import { getDatasetACLCapabilities, hasPermission } from '@/utils/permission'
|
||||
|
||||
type DatasetAccessMockState = {
|
||||
userProfile?: {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
avatar_url?: string
|
||||
is_password_set?: boolean
|
||||
} | null
|
||||
currentWorkspace?: {
|
||||
id?: string
|
||||
} | null
|
||||
isCurrentWorkspaceOwner?: boolean
|
||||
isLoadingCurrentWorkspace?: boolean
|
||||
isLoadingWorkspacePermissionKeys?: boolean
|
||||
workspacePermissionKeys?: string[]
|
||||
}
|
||||
|
||||
type DatasetAccessMockOptions = {
|
||||
isRbacEnabled?: boolean
|
||||
}
|
||||
|
||||
type DatasetAccessResource = {
|
||||
maintainer?: string | null
|
||||
permission_keys?: readonly PermissionKey[] | null
|
||||
}
|
||||
|
||||
const defaultUserProfile = {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
avatar_url: '',
|
||||
is_password_set: true,
|
||||
}
|
||||
|
||||
export const createDatasetAccessHookMock = (
|
||||
getState: () => DatasetAccessMockState,
|
||||
getOptions: () => DatasetAccessMockOptions = () => ({}),
|
||||
) => {
|
||||
const getUserProfile = () => ({
|
||||
...defaultUserProfile,
|
||||
...getState().userProfile,
|
||||
})
|
||||
|
||||
const getWorkspacePermissionKeys = () => getState().workspacePermissionKeys ?? []
|
||||
|
||||
return {
|
||||
useDatasetRbacEnabled: () => getOptions().isRbacEnabled ?? true,
|
||||
useDatasetCurrentUser: getUserProfile,
|
||||
useDatasetWorkspaceAccess: () => {
|
||||
const state = getState()
|
||||
const workspacePermissionKeys = getWorkspacePermissionKeys()
|
||||
const isLoadingCurrentWorkspace = state.isLoadingCurrentWorkspace ?? false
|
||||
const isLoadingWorkspacePermissionKeys = state.isLoadingWorkspacePermissionKeys ?? false
|
||||
|
||||
return {
|
||||
currentWorkspaceId: state.currentWorkspace?.id ?? 'workspace-1',
|
||||
isCurrentWorkspaceOwner: state.isCurrentWorkspaceOwner ?? false,
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
isLoadingAccess: isLoadingCurrentWorkspace || isLoadingWorkspacePermissionKeys,
|
||||
workspacePermissionKeys,
|
||||
canCreateDataset: hasPermission(workspacePermissionKeys, 'dataset.create_and_management'),
|
||||
canConnectExternalDataset: hasPermission(workspacePermissionKeys, 'dataset.external.connect'),
|
||||
canManageDatasetTags: hasPermission(workspacePermissionKeys, 'dataset.tag.manage'),
|
||||
canManageDatasetApiKeys: hasPermission(workspacePermissionKeys, 'dataset.api_key.manage'),
|
||||
}
|
||||
},
|
||||
useDatasetACLCapabilities: (
|
||||
resource: DatasetAccessResource | null | undefined,
|
||||
options?: { isRbacEnabled?: boolean },
|
||||
) => getDatasetACLCapabilities(resource?.permission_keys, {
|
||||
currentUserId: getUserProfile().id,
|
||||
resourceMaintainer: resource?.maintainer,
|
||||
workspacePermissionKeys: getWorkspacePermissionKeys(),
|
||||
isRbacEnabled: options?.isRbacEnabled,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import {
|
||||
currentWorkspaceAtom,
|
||||
currentWorkspaceLoadingAtom,
|
||||
systemFeaturesAtom,
|
||||
userProfileAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
workspaceRoleFlagsAtom,
|
||||
} from '@/context/app-context-state'
|
||||
import { DatasetACLPermission } from '@/utils/permission'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetCurrentUser,
|
||||
useDatasetRbacEnabled,
|
||||
useDatasetWorkspaceAccess,
|
||||
} from '../use-dataset-access'
|
||||
|
||||
const mockAtomValues = vi.hoisted(() => new Map<unknown, unknown>())
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('jotai')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useAtomValue: (atom: unknown) => mockAtomValues.get(atom),
|
||||
}
|
||||
})
|
||||
|
||||
const setDatasetAccessAtomValues = () => {
|
||||
mockAtomValues.clear()
|
||||
mockAtomValues.set(systemFeaturesAtom, { rbac_enabled: true })
|
||||
mockAtomValues.set(userProfileAtom, {
|
||||
id: 'user-1',
|
||||
name: 'User 1',
|
||||
email: 'user1@example.com',
|
||||
})
|
||||
mockAtomValues.set(currentWorkspaceAtom, { id: 'workspace-1' })
|
||||
mockAtomValues.set(workspaceRoleFlagsAtom, { isCurrentWorkspaceOwner: true })
|
||||
mockAtomValues.set(currentWorkspaceLoadingAtom, false)
|
||||
mockAtomValues.set(workspacePermissionKeysLoadingAtom, false)
|
||||
mockAtomValues.set(workspacePermissionKeysAtom, [
|
||||
'dataset.create_and_management',
|
||||
'dataset.external.connect',
|
||||
'dataset.tag.manage',
|
||||
'dataset.api_key.manage',
|
||||
])
|
||||
}
|
||||
|
||||
describe('useDatasetAccess hooks', () => {
|
||||
beforeEach(() => {
|
||||
setDatasetAccessAtomValues()
|
||||
})
|
||||
|
||||
it('should read RBAC status from the system features atom', () => {
|
||||
mockAtomValues.set(systemFeaturesAtom, { rbac_enabled: false })
|
||||
|
||||
const { result } = renderHook(() => useDatasetRbacEnabled())
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose workspace access derived from state atoms', () => {
|
||||
mockAtomValues.set(workspacePermissionKeysLoadingAtom, true)
|
||||
|
||||
const { result } = renderHook(() => useDatasetWorkspaceAccess())
|
||||
|
||||
expect(result.current).toMatchObject({
|
||||
currentWorkspaceId: 'workspace-1',
|
||||
isCurrentWorkspaceOwner: true,
|
||||
isLoadingCurrentWorkspace: false,
|
||||
isLoadingWorkspacePermissionKeys: true,
|
||||
isLoadingAccess: true,
|
||||
canCreateDataset: true,
|
||||
canConnectExternalDataset: true,
|
||||
canManageDatasetTags: true,
|
||||
canManageDatasetApiKeys: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose the current user from the user profile atom', () => {
|
||||
const { result } = renderHook(() => useDatasetCurrentUser())
|
||||
|
||||
expect(result.current).toMatchObject({
|
||||
id: 'user-1',
|
||||
email: 'user1@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should compute dataset ACL capabilities with current user and workspace permissions', () => {
|
||||
const { result } = renderHook(() => useDatasetACLCapabilities({
|
||||
maintainer: 'user-1',
|
||||
permission_keys: [DatasetACLPermission.Readonly],
|
||||
}, {
|
||||
isRbacEnabled: true,
|
||||
}))
|
||||
|
||||
expect(result.current.canReadonly).toBe(true)
|
||||
expect(result.current.canEdit).toBe(true)
|
||||
expect(result.current.canAccessConfig).toBe(true)
|
||||
})
|
||||
})
|
||||
83
web/app/components/datasets/hooks/use-dataset-access.ts
Normal file
83
web/app/components/datasets/hooks/use-dataset-access.ts
Normal file
@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { PermissionKey } from '@/models/access-control'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
currentWorkspaceAtom,
|
||||
currentWorkspaceLoadingAtom,
|
||||
systemFeaturesAtom,
|
||||
userProfileAtom,
|
||||
workspacePermissionKeysAtom,
|
||||
workspacePermissionKeysLoadingAtom,
|
||||
workspaceRoleFlagsAtom,
|
||||
} from '@/context/app-context-state'
|
||||
import { getDatasetACLCapabilities, hasPermission } from '@/utils/permission'
|
||||
|
||||
type DatasetAccessResource = {
|
||||
maintainer?: string | null
|
||||
permission_keys?: readonly PermissionKey[] | null
|
||||
}
|
||||
|
||||
export const useDatasetRbacEnabled = () => {
|
||||
const systemFeatures = useAtomValue(systemFeaturesAtom)
|
||||
|
||||
return systemFeatures.rbac_enabled
|
||||
}
|
||||
|
||||
const useDatasetPermissionContext = () => {
|
||||
const currentUser = useAtomValue(userProfileAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
|
||||
return {
|
||||
currentUserId: currentUser.id,
|
||||
workspacePermissionKeys,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDatasetWorkspaceAccess = () => {
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
|
||||
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
|
||||
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
|
||||
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const canManageDatasetTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
|
||||
const canManageDatasetApiKeys = hasPermission(workspacePermissionKeys, 'dataset.api_key.manage')
|
||||
|
||||
return {
|
||||
currentWorkspaceId: currentWorkspace.id,
|
||||
isCurrentWorkspaceOwner: roleFlags.isCurrentWorkspaceOwner,
|
||||
isLoadingCurrentWorkspace,
|
||||
isLoadingWorkspacePermissionKeys,
|
||||
isLoadingAccess: isLoadingCurrentWorkspace || !!isLoadingWorkspacePermissionKeys,
|
||||
workspacePermissionKeys,
|
||||
canCreateDataset,
|
||||
canConnectExternalDataset,
|
||||
canManageDatasetTags,
|
||||
canManageDatasetApiKeys,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDatasetCurrentUser = () => {
|
||||
return useAtomValue(userProfileAtom)
|
||||
}
|
||||
|
||||
export const useDatasetACLCapabilities = (
|
||||
resource: DatasetAccessResource | null | undefined,
|
||||
options?: { isRbacEnabled?: boolean },
|
||||
) => {
|
||||
const {
|
||||
currentUserId,
|
||||
workspacePermissionKeys,
|
||||
} = useDatasetPermissionContext()
|
||||
|
||||
return useMemo(() => getDatasetACLCapabilities(resource?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: resource?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled: options?.isRbacEnabled,
|
||||
}), [currentUserId, options?.isRbacEnabled, resource?.maintainer, resource?.permission_keys, workspacePermissionKeys])
|
||||
}
|
||||
@ -3,7 +3,6 @@ import type { useDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { RETRIEVE_METHOD } from '@/types/app'
|
||||
import Datasets from '../datasets'
|
||||
@ -70,22 +69,9 @@ vi.mock('../../rename-modal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../dataset-card', () => ({
|
||||
default: ({
|
||||
dataset,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
stepByStepTourActionMenuOpen,
|
||||
stepByStepTourCardTarget,
|
||||
}: {
|
||||
dataset: DataSet
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourCardTarget?: string
|
||||
}) => (
|
||||
default: ({ dataset }: { dataset: DataSet }) => (
|
||||
<article data-testid={`dataset-card-${dataset.id}`}>
|
||||
{dataset.name}
|
||||
<span data-testid={`dataset-card-target-${dataset.id}`}>{stepByStepTourCardTarget}</span>
|
||||
<span data-testid={`dataset-card-menu-open-${dataset.id}`}>{String(stepByStepTourActionMenuOpen)}</span>
|
||||
<span data-testid={`dataset-card-highlight-${dataset.id}`}>{stepByStepTourActionMenuHighlightPart}</span>
|
||||
</article>
|
||||
),
|
||||
}))
|
||||
@ -206,26 +192,6 @@ describe('Datasets', () => {
|
||||
expect(screen.getByText('Dataset 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass step-by-step tour targets to the first dataset card only', () => {
|
||||
render((
|
||||
<Datasets
|
||||
{...defaultProps}
|
||||
stepByStepTourActionMenuOpen
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
|
||||
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
|
||||
/>
|
||||
))
|
||||
|
||||
expect(screen.getByTestId('dataset-card-target-dataset-1'))
|
||||
.toHaveTextContent(STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard)
|
||||
expect(screen.getByTestId('dataset-card-menu-open-dataset-1')).toHaveTextContent('true')
|
||||
expect(screen.getByTestId('dataset-card-highlight-dataset-1'))
|
||||
.toHaveTextContent(STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu)
|
||||
expect(screen.getByTestId('dataset-card-target-dataset-2')).toBeEmptyDOMElement()
|
||||
expect(screen.getByTestId('dataset-card-menu-open-dataset-2')).toHaveTextContent('undefined')
|
||||
expect(screen.getByTestId('dataset-card-highlight-dataset-2')).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render empty element when there are no datasets', () => {
|
||||
render(
|
||||
<Datasets
|
||||
|
||||
@ -1,30 +1,17 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import DatasetListHeader from '../header'
|
||||
|
||||
vi.mock('@langgenius/dify-ui/button', () => ({
|
||||
Button: ({ children, className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button type="button" className={className} {...props}>{children}</button>
|
||||
Button: ({ children, className }: { children: React.ReactNode, className?: string }) => (
|
||||
<button type="button" className={className}>{children}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuContent: ({
|
||||
children,
|
||||
popupProps,
|
||||
positionerProps,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) => (
|
||||
<div {...positionerProps}>
|
||||
<div role="menu" {...popupProps}>{children}</div>
|
||||
</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
|
||||
<button type="button" role="menuitem" className={className} onClick={onClick}>{children}</button>
|
||||
<button type="button" className={className} onClick={onClick}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: ({ className }: { className?: string }) => <hr data-testid="create-menu-separator" className={className} />,
|
||||
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => render,
|
||||
@ -68,9 +55,9 @@ describe('DatasetListHeader', () => {
|
||||
it('uses the updated create menu labels and pipeline icon', () => {
|
||||
render(<DatasetListHeader {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).toBeInTheDocument()
|
||||
|
||||
const menuItem = screen.getByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ })
|
||||
const menuItem = screen.getByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ })
|
||||
|
||||
expect(menuItem.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
|
||||
})
|
||||
@ -78,9 +65,9 @@ describe('DatasetListHeader', () => {
|
||||
it('should hide dataset creation actions when dataset.create_and_management is unavailable', () => {
|
||||
render(<DatasetListHeader {...defaultProps} canCreateDataset={false} />)
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('menuitem', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: /dataset\.connectDataset/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.createTitle/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /dataset\.firstEmpty\.pipelineTitle/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /dataset\.connectDataset/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide external API panel entry when dataset.external.connect is unavailable', () => {
|
||||
@ -100,38 +87,4 @@ describe('DatasetListHeader', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes step-by-step tour targets for the create menu walkthrough', () => {
|
||||
render((
|
||||
<DatasetListHeader
|
||||
{...defaultProps}
|
||||
stepByStepTourCreateMenuOpen
|
||||
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
|
||||
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate)
|
||||
expect(screen.getByText('dataset.firstEmpty.createTitle')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true })).toBeInTheDocument()
|
||||
const createMenuHighlightPart = document.body.querySelector(`[data-step-by-step-tour-highlight-part="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}"]`)
|
||||
expect(createMenuHighlightPart).toBeInTheDocument()
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('keeps the tour-opened create menu as presentation only', () => {
|
||||
render((
|
||||
<DatasetListHeader
|
||||
{...defaultProps}
|
||||
stepByStepTourCreateMenuOpen
|
||||
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
|
||||
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
|
||||
/>
|
||||
))
|
||||
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'dataset.firstEmpty.createTitle', hidden: true }))
|
||||
|
||||
expect(defaultProps.onCreateDataset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { StepByStepTourAccountState } from '@/app/components/step-by-step-tour/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '../index'
|
||||
@ -9,44 +8,10 @@ const mockReplace = vi.fn()
|
||||
let mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}
|
||||
let mockIsCurrentWorkspaceOwner = true
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const createState = (
|
||||
overrides: Partial<StepByStepTourAccountState> = {},
|
||||
): StepByStepTourAccountState => ({
|
||||
activeGuideGroup: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideIndexes: undefined,
|
||||
activeTaskId: undefined,
|
||||
completedTaskIds: ['home', 'studio'],
|
||||
eligible: true,
|
||||
firstWorkspaceId: 'workspace-1',
|
||||
manuallyDisabledWorkspaceIds: [],
|
||||
manuallyEnabledWorkspaceIds: ['workspace-1'],
|
||||
minimized: true,
|
||||
skipped: false,
|
||||
updatedAt: null,
|
||||
...overrides,
|
||||
})
|
||||
let state = createState()
|
||||
|
||||
return {
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
reset() {
|
||||
state = createState()
|
||||
},
|
||||
setState(nextState: StepByStepTourAccountState) {
|
||||
state = nextState
|
||||
},
|
||||
setTestState(overrides: Partial<StepByStepTourAccountState> = {}) {
|
||||
state = createState(overrides)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
@ -54,13 +19,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/step-by-step-tour/storage', () => ({
|
||||
useSetStepByStepTourAccountState: () => (nextState: StepByStepTourAccountState) => {
|
||||
mockStepByStepTour.setState(nextState)
|
||||
},
|
||||
useStepByStepTourAccountStateValue: () => mockStepByStepTour.state,
|
||||
}))
|
||||
|
||||
// Mock app context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
@ -70,6 +28,12 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState)
|
||||
})
|
||||
|
||||
// Mock external api panel context
|
||||
const mockSetShowExternalApiPanel = vi.fn()
|
||||
vi.mock('@/context/external-api-panel-context', () => ({
|
||||
@ -172,11 +136,10 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', ()
|
||||
describe('List', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockStepByStepTour.reset()
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}
|
||||
mockIsCurrentWorkspaceOwner = true
|
||||
@ -215,6 +178,7 @@ describe('List', () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}
|
||||
|
||||
@ -327,6 +291,7 @@ describe('List', () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
@ -344,63 +309,11 @@ describe('List', () => {
|
||||
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ })).toHaveAttribute('href', '/datasets/create-from-pipeline')
|
||||
})
|
||||
|
||||
it('should activate the Knowledge empty walkthrough for users with all empty-state permissions', async () => {
|
||||
mockStepByStepTour.setTestState({
|
||||
activeTaskId: 'knowledge',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [], total: 0 }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<List />)
|
||||
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.state
|
||||
expect(state.activeGuideGroup).toBe('knowledgeEmpty')
|
||||
expect(state.activeGuideIndex).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('should not activate the Knowledge empty walkthrough until all three empty-state actions are available', async () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}
|
||||
mockStepByStepTour.setTestState({
|
||||
activeTaskId: 'knowledge',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
vi.mocked(useDatasetList).mockReturnValue({
|
||||
data: { pages: [{ data: [], total: 0 }] },
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
} as unknown as ReturnType<typeof useDatasetList>)
|
||||
|
||||
render(<List />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dataset.firstEmpty.title')).toBeInTheDocument()
|
||||
})
|
||||
const state = mockStepByStepTour.state
|
||||
expect(state.activeGuideGroup).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should not render first empty state for legacy editors without dataset creation permissions', async () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: [],
|
||||
}
|
||||
const { useDatasetList } = await import('@/service/knowledge/use-dataset')
|
||||
@ -465,6 +378,7 @@ describe('List', () => {
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
|
||||
isCurrentWorkspaceEditor: false,
|
||||
isCurrentWorkspaceManager: false,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}),
|
||||
}))
|
||||
@ -515,6 +429,12 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should not show ExternalAPIPanel without dataset.external.connect even when panel state is open', async () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'admin' },
|
||||
@ -523,6 +443,7 @@ describe('List', () => {
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
}),
|
||||
}))
|
||||
@ -550,6 +471,7 @@ describe('List', () => {
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: true,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}),
|
||||
}))
|
||||
@ -579,6 +501,12 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should not show include all checkbox when not workspace owner', async () => {
|
||||
mockAppContextState = {
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}
|
||||
vi.doMock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: { role: 'editor' },
|
||||
@ -587,6 +515,7 @@ describe('List', () => {
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector({
|
||||
isCurrentWorkspaceEditor: true,
|
||||
isCurrentWorkspaceManager: true,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
workspacePermissionKeys: ['dataset.create_and_management', 'dataset.external.connect'],
|
||||
}),
|
||||
}))
|
||||
|
||||
@ -3,7 +3,6 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
|
||||
import { DatasetACLPermission } from '@/utils/permission'
|
||||
import DatasetCardFooter from '../components/dataset-card-footer'
|
||||
@ -54,6 +53,12 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: (selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState)
|
||||
})
|
||||
|
||||
vi.mock('../hooks/use-dataset-card-state', () => ({
|
||||
useDatasetCardState: () => ({
|
||||
modalState: {
|
||||
@ -95,26 +100,15 @@ vi.mock('@/features/tag-management/components/dataset-card-tags', () => ({
|
||||
<div
|
||||
data-testid="tag-area"
|
||||
data-can-bind-or-unbind-tags={String(Boolean(canBindOrUnbindTags))}
|
||||
role="presentation"
|
||||
onClick={onClick}
|
||||
onKeyDown={() => undefined}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
vi.mock('../components/operations-dropdown', () => ({
|
||||
default: ({
|
||||
openAccessConfig,
|
||||
stepByStepTourHighlightPart,
|
||||
stepByStepTourOpen,
|
||||
}: {
|
||||
openAccessConfig?: () => void
|
||||
stepByStepTourHighlightPart?: string
|
||||
stepByStepTourOpen?: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-testid="operations-dropdown"
|
||||
data-has-open-access-config={typeof openAccessConfig === 'function'}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourHighlightPart}
|
||||
data-step-by-step-tour-open={String(stepByStepTourOpen)}
|
||||
/>
|
||||
default: ({ openAccessConfig }: { openAccessConfig?: () => void }) => (
|
||||
<div data-testid="operations-dropdown" data-has-open-access-config={typeof openAccessConfig === 'function'} />
|
||||
),
|
||||
}))
|
||||
|
||||
@ -153,27 +147,6 @@ describe('DatasetCard Integration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('Step-by-step tour targets', () => {
|
||||
it('should expose card and operations targets for the Knowledge walkthrough', () => {
|
||||
const dataset = createMockDataset()
|
||||
|
||||
const { container } = render(
|
||||
<DatasetCard
|
||||
dataset={dataset}
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
|
||||
stepByStepTourActionMenuOpen
|
||||
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector(`[data-step-by-step-tour-target="${STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}"]`))
|
||||
.toBeInTheDocument()
|
||||
expect(screen.getByTestId('operations-dropdown'))
|
||||
.toHaveAttribute('data-step-by-step-tour-highlight-part', STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu)
|
||||
expect(screen.getByTestId('operations-dropdown')).toHaveAttribute('data-step-by-step-tour-open', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
// Integration tests for Description component
|
||||
describe('Description', () => {
|
||||
describe('Rendering', () => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { createEvent, fireEvent, screen } from '@testing-library/react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
@ -13,7 +13,6 @@ const mockAppContextState = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
let mockIsRbacEnabled = true
|
||||
const noopKeyboardHandler = () => {}
|
||||
|
||||
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
|
||||
systemFeatures: {
|
||||
@ -25,6 +24,14 @@ vi.mock('@/context/app-context', () => ({
|
||||
useSelector: vi.fn((selector: (state: typeof mockAppContextState) => unknown) => selector(mockAppContextState)),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
|
||||
isRbacEnabled: mockIsRbacEnabled,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('OperationsDropdown', () => {
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
@ -209,73 +216,6 @@ describe('OperationsDropdown', () => {
|
||||
expect(onOutsideClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should prevent the card click default behavior when opening the menu', () => {
|
||||
render(<OperationsDropdown {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByLabelText('Dataset operations')
|
||||
const event = createEvent.click(trigger)
|
||||
|
||||
fireEvent(trigger, event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('should keep menu item clicks from bubbling to the card while running the item action', async () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
const onCardClick = vi.fn()
|
||||
|
||||
render(
|
||||
<div role="button" tabIndex={0} onClick={onCardClick} onKeyDown={noopKeyboardHandler}>
|
||||
<OperationsDropdown
|
||||
{...defaultProps}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dataset operations'))
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
|
||||
|
||||
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
|
||||
expect(onCardClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep the tour-opened operations menu open when its trigger is clicked', async () => {
|
||||
render((
|
||||
<OperationsDropdown
|
||||
{...defaultProps}
|
||||
stepByStepTourHighlightPart="knowledge-card-actions-menu"
|
||||
stepByStepTourOpen
|
||||
/>
|
||||
))
|
||||
|
||||
expect(await screen.findByText('common.operation.edit')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Dataset operations'))
|
||||
|
||||
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveClass('pointer-events-none')
|
||||
})
|
||||
|
||||
it('should keep tour-opened operations menu items from running actions', async () => {
|
||||
const detectIsUsedByApp = vi.fn()
|
||||
|
||||
render((
|
||||
<OperationsDropdown
|
||||
{...defaultProps}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
stepByStepTourHighlightPart="knowledge-card-actions-menu"
|
||||
stepByStepTourOpen
|
||||
/>
|
||||
))
|
||||
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'common.operation.delete', hidden: true }))
|
||||
|
||||
expect(detectIsUsedByApp).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menu', { hidden: true })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass openRenameModal to Operations', () => {
|
||||
const openRenameModal = vi.fn()
|
||||
render(<OperationsDropdown {...defaultProps} openRenameModal={openRenameModal} />)
|
||||
|
||||
@ -5,12 +5,11 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetRbacEnabled,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import Operations from '../operations'
|
||||
|
||||
type OperationsDropdownProps = {
|
||||
@ -19,8 +18,6 @@ type OperationsDropdownProps = {
|
||||
handleExportPipeline: (include?: boolean) => void
|
||||
detectIsUsedByApp: () => void
|
||||
openAccessConfig: () => void
|
||||
stepByStepTourHighlightPart?: string
|
||||
stepByStepTourOpen?: boolean
|
||||
}
|
||||
|
||||
const OperationsDropdown = ({
|
||||
@ -29,23 +26,10 @@ const OperationsDropdown = ({
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
openAccessConfig,
|
||||
stepByStepTourHighlightPart,
|
||||
stepByStepTourOpen,
|
||||
}: OperationsDropdownProps) => {
|
||||
const menu = useStepByStepTourControlledDropdown({
|
||||
allowTriggerCloseWhileControlled: false,
|
||||
controlledOpen: stepByStepTourOpen,
|
||||
})
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const datasetACLCapabilities = React.useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset.maintainer,
|
||||
workspacePermissionKeys,
|
||||
isRbacEnabled,
|
||||
}), [dataset.maintainer, dataset.permission_keys, currentUserId, isRbacEnabled, workspacePermissionKeys])
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const isRbacEnabled = useDatasetRbacEnabled()
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset, { isRbacEnabled })
|
||||
const canShowOperations = datasetACLCapabilities.canEdit
|
||||
|| datasetACLCapabilities.canImportExportDSL
|
||||
|| datasetACLCapabilities.canAccessConfig
|
||||
@ -58,12 +42,15 @@ const OperationsDropdown = ({
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-2 right-2 z-5',
|
||||
menu.open
|
||||
open
|
||||
? 'pointer-events-auto visible'
|
||||
: 'pointer-events-none invisible group-hover:pointer-events-auto group-hover:visible',
|
||||
)}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false} open={menu.open} onOpenChange={menu.onOpenChange}>
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'inline-flex size-9 cursor-pointer items-center justify-center rounded-[10px] border-[0.5px]',
|
||||
@ -73,21 +60,12 @@ const OperationsDropdown = ({
|
||||
'data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
aria-label="Dataset operations"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-more-fill size-5 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
disableMotion: menu.controlled,
|
||||
highlightPart: menu.controlled ? stepByStepTourHighlightPart : undefined,
|
||||
interactionMode: menu.controlled ? 'presentation' : 'interactive',
|
||||
popupClassName: 'min-w-[186px]',
|
||||
})}
|
||||
popupClassName="min-w-[186px]"
|
||||
>
|
||||
<Operations
|
||||
showEdit={datasetACLCapabilities.canEdit}
|
||||
@ -98,7 +76,6 @@ const OperationsDropdown = ({
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
openAccessConfig={openAccessConfig}
|
||||
onClose={menu.close}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@ -5,10 +5,13 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import {
|
||||
useDatasetACLCapabilities,
|
||||
useDatasetWorkspaceAccess,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { DatasetCardTags } from '@/features/tag-management/components/dataset-card-tags'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { getDatasetACLCapabilities, hasOnlyDatasetPreviewPermission, hasPermission } from '@/utils/permission'
|
||||
import { hasOnlyDatasetPreviewPermission } from '@/utils/permission'
|
||||
import CornerLabels from './components/corner-labels'
|
||||
import DatasetCardFooter from './components/dataset-card-footer'
|
||||
import DatasetCardHeader from './components/dataset-card-header'
|
||||
@ -23,23 +26,16 @@ type DatasetCardProps = {
|
||||
dataset: DataSet
|
||||
onSuccess?: () => void
|
||||
onOpenTagManagement?: () => void
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourCardTarget?: string
|
||||
}
|
||||
|
||||
const DatasetCard = ({
|
||||
dataset,
|
||||
onSuccess,
|
||||
onOpenTagManagement = () => {},
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
stepByStepTourActionMenuOpen,
|
||||
stepByStepTourCardTarget,
|
||||
}: DatasetCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const { canManageDatasetTags } = useDatasetWorkspaceAccess()
|
||||
|
||||
const datasetCard = useDatasetCardController({ dataset, onSuccess })
|
||||
const {
|
||||
@ -59,13 +55,8 @@ const DatasetCard = ({
|
||||
return dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
}, [dataset.runtime_mode, dataset.is_published])
|
||||
const isPreviewOnly = hasOnlyDatasetPreviewPermission(dataset.permission_keys)
|
||||
const datasetACLCapabilities = useMemo(() => getDatasetACLCapabilities(dataset.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: dataset.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}), [dataset.maintainer, dataset.permission_keys, currentUserId, workspacePermissionKeys])
|
||||
const canManageAppTags = hasPermission(workspacePermissionKeys, 'dataset.tag.manage')
|
||||
const canBindOrUnbindTags = !isPreviewOnly && (canManageAppTags || datasetACLCapabilities.canEdit)
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(dataset)
|
||||
const canBindOrUnbindTags = !isPreviewOnly && (canManageDatasetTags || datasetACLCapabilities.canEdit)
|
||||
|
||||
const showPreviewOnlyAccessWarning = () => {
|
||||
toast.warning(t('noAccessResourcePermission', { ns: 'app' }))
|
||||
@ -119,7 +110,6 @@ const DatasetCard = ({
|
||||
aria-label={isPreviewOnly ? dataset.name : undefined}
|
||||
className={cardClassName}
|
||||
data-disable-nprogress={true}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
>
|
||||
@ -143,8 +133,6 @@ const DatasetCard = ({
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
openAccessConfig={openAccessConfig}
|
||||
stepByStepTourHighlightPart={stepByStepTourActionMenuHighlightPart}
|
||||
stepByStepTourOpen={stepByStepTourActionMenuOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -19,9 +19,6 @@ type Props = Readonly<{
|
||||
isPlaceholderData: ReturnType<typeof useDatasetList>['isPlaceholderData']
|
||||
emptyElement?: ReactNode
|
||||
onOpenTagManagement?: () => void
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourCardTarget?: string
|
||||
}>
|
||||
|
||||
const Datasets = ({
|
||||
@ -34,9 +31,6 @@ const Datasets = ({
|
||||
isPlaceholderData,
|
||||
emptyElement,
|
||||
onOpenTagManagement = () => { },
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
stepByStepTourActionMenuOpen,
|
||||
stepByStepTourCardTarget,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
@ -70,17 +64,8 @@ const Datasets = ({
|
||||
<nav className="relative grid grow grid-cols-[repeat(auto-fill,minmax(296px,1fr))] content-start gap-3 px-8 pt-2">
|
||||
{showDatasetSkeleton
|
||||
? <DatasetCardSkeleton label={t('loading', { ns: 'common' })} />
|
||||
: datasets.map((dataset, index) => (
|
||||
<DatasetCard
|
||||
key={dataset.id}
|
||||
dataset={dataset}
|
||||
onSuccess={invalidDatasetList}
|
||||
onOpenTagManagement={onOpenTagManagement}
|
||||
stepByStepTourActionMenuHighlightPart={index === 0 && stepByStepTourActionMenuOpen ? stepByStepTourActionMenuHighlightPart : undefined}
|
||||
stepByStepTourActionMenuOpen={index === 0 ? stepByStepTourActionMenuOpen : undefined}
|
||||
stepByStepTourCardTarget={index === 0 ? stepByStepTourCardTarget : undefined}
|
||||
/>
|
||||
),
|
||||
: datasets.map(dataset => (
|
||||
<DatasetCard key={dataset.id} dataset={dataset} onSuccess={invalidDatasetList} onOpenTagManagement={onOpenTagManagement} />),
|
||||
)}
|
||||
{!showDatasetSkeleton && !hasAnyDataset && emptyElement}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import DatasetFirstEmptyState from '..'
|
||||
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href, className, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => (
|
||||
<a href={href} className={className} {...props}>{children}</a>
|
||||
default: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
@ -18,17 +17,6 @@ describe('DatasetFirstEmptyState', () => {
|
||||
expect(pipelineLink.querySelector('.i-custom-vender-pipeline-pipeline-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes step-by-step tour targets for the empty knowledge actions', () => {
|
||||
render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
|
||||
|
||||
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.createTitle/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate)
|
||||
expect(screen.getByRole('link', { name: /dataset\.firstEmpty\.pipelineTitle/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline)
|
||||
expect(screen.getByRole('link', { name: /dataset\.connectDataset/ }))
|
||||
.toHaveAttribute('data-step-by-step-tour-target', STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect)
|
||||
})
|
||||
|
||||
it('lays out placeholder cards with auto-fill grid columns', () => {
|
||||
const { container } = render(<DatasetFirstEmptyState canConnectExternalDataset canCreateDataset />)
|
||||
const placeholderGrid = Array.from(container.querySelectorAll('.pointer-events-none'))
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
|
||||
const EMPTY_PLACEHOLDER_CARD_IDS = Array.from({ length: 16 }, (_, index) => `dataset-placeholder-card-${index}`)
|
||||
|
||||
@ -10,7 +9,6 @@ type EmptyCreateAction = {
|
||||
href: string
|
||||
icon: ReactNode
|
||||
id: string
|
||||
target: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
@ -33,7 +31,6 @@ function DatasetFirstEmptyState({
|
||||
href: '/datasets/create',
|
||||
icon: <span aria-hidden className="i-ri-add-line size-4" />,
|
||||
id: 'create',
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyCreate,
|
||||
title: t('firstEmpty.createTitle', { ns: 'dataset' }),
|
||||
description: t('firstEmpty.createDescription', { ns: 'dataset' }),
|
||||
},
|
||||
@ -41,7 +38,6 @@ function DatasetFirstEmptyState({
|
||||
href: '/datasets/create-from-pipeline',
|
||||
icon: <span aria-hidden className="i-custom-vender-pipeline-pipeline-line size-4" />,
|
||||
id: 'pipeline',
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyPipeline,
|
||||
title: t('firstEmpty.pipelineTitle', { ns: 'dataset' }),
|
||||
description: t('firstEmpty.pipelineDescription', { ns: 'dataset' }),
|
||||
},
|
||||
@ -52,7 +48,6 @@ function DatasetFirstEmptyState({
|
||||
href: '/datasets/connect',
|
||||
icon: <span aria-hidden className="i-custom-vender-solid-development-api-connection-mod size-4" />,
|
||||
id: 'connect',
|
||||
target: STEP_BY_STEP_TOUR_TARGETS.knowledgeEmptyConnect,
|
||||
title: t('connectDataset', { ns: 'dataset' }),
|
||||
description: t('firstEmpty.connectDescription', { ns: 'dataset' }),
|
||||
}
|
||||
@ -93,7 +88,6 @@ function DatasetFirstEmptyState({
|
||||
description={action.description}
|
||||
href={action.href}
|
||||
icon={action.icon}
|
||||
stepByStepTourTarget={action.target}
|
||||
title={action.title}
|
||||
visualStyle="list"
|
||||
/>
|
||||
@ -112,7 +106,6 @@ function DatasetFirstEmptyState({
|
||||
description={connectAction.description}
|
||||
href={connectAction.href}
|
||||
icon={connectAction.icon}
|
||||
stepByStepTourTarget={connectAction.target}
|
||||
title={connectAction.title}
|
||||
visualStyle="list"
|
||||
/>
|
||||
|
||||
@ -5,7 +5,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import { getStepByStepTourDropdownMenuContentProps, useStepByStepTourControlledDropdown } from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import ServiceApi from '../extra-info/service-api'
|
||||
|
||||
@ -25,9 +24,6 @@ type Props = {
|
||||
onKeywordsChange: (value: string) => void
|
||||
onOpenTagManagement: () => void
|
||||
onTagsChange: (value: string[]) => void
|
||||
stepByStepTourCreateMenuHighlightPart?: string
|
||||
stepByStepTourCreateMenuOpen?: boolean
|
||||
stepByStepTourCreateMenuTarget?: string
|
||||
}
|
||||
|
||||
const DatasetListHeader = ({
|
||||
@ -46,15 +42,9 @@ const DatasetListHeader = ({
|
||||
onKeywordsChange,
|
||||
onOpenTagManagement,
|
||||
onTagsChange,
|
||||
stepByStepTourCreateMenuHighlightPart,
|
||||
stepByStepTourCreateMenuOpen,
|
||||
stepByStepTourCreateMenuTarget,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const showCreateMenu = canCreateDataset || canConnectExternalDataset
|
||||
const createMenu = useStepByStepTourControlledDropdown({
|
||||
controlledOpen: stepByStepTourCreateMenuOpen,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-col gap-[14px] bg-background-body px-8 pt-4 pb-2">
|
||||
@ -104,14 +94,13 @@ const DatasetListHeader = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showCreateMenu && (
|
||||
<DropdownMenu modal={false} open={createMenu.open} onOpenChange={createMenu.onOpenChange}>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<Button
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-0.5 px-2 shadow-xs"
|
||||
data-step-by-step-tour-target={stepByStepTourCreateMenuTarget}
|
||||
>
|
||||
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
|
||||
<span className="pl-1">{t('operation.create', { ns: 'common' })}</span>
|
||||
@ -122,12 +111,7 @@ const DatasetListHeader = ({
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
disableMotion: createMenu.controlled,
|
||||
highlightPart: createMenu.controlled ? stepByStepTourCreateMenuHighlightPart : undefined,
|
||||
interactionMode: createMenu.controlled ? 'presentation' : 'interactive',
|
||||
popupClassName: 'w-80',
|
||||
})}
|
||||
popupClassName="w-80"
|
||||
>
|
||||
{canCreateDataset && (
|
||||
<>
|
||||
|
||||
@ -3,20 +3,14 @@
|
||||
import { useBoolean, useDebounceFn } from 'ahooks'
|
||||
|
||||
// Libraries
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useSetStepByStepTourAccountState,
|
||||
useStepByStepTourAccountStateValue,
|
||||
} from '@/app/components/step-by-step-tour/storage'
|
||||
import { getStepByStepTourGuides, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
|
||||
import { useDatasetWorkspaceAccess } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||
import { TagManagementModal } from '@/features/tag-management/components/tag-management-modal'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDatasetApiBaseUrl, useDatasetList, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
// Components
|
||||
import FilterEmptyState from '../../base/filter-empty-state'
|
||||
import ExternalAPIPanel from '../external-api/external-api-panel'
|
||||
@ -27,7 +21,11 @@ import DatasetListHeader from './header'
|
||||
const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const { isCurrentWorkspaceOwner } = useAppContext()
|
||||
const {
|
||||
isCurrentWorkspaceOwner,
|
||||
canCreateDataset,
|
||||
canConnectExternalDataset,
|
||||
} = useDatasetWorkspaceAccess()
|
||||
const [showTagManagementModal, setShowTagManagementModal] = useState(false)
|
||||
const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel()
|
||||
const [includeAll, { toggle: toggleIncludeAll }] = useBoolean(false)
|
||||
@ -53,13 +51,6 @@ const List = () => {
|
||||
handleTagsUpdate()
|
||||
}
|
||||
|
||||
const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys)
|
||||
const canCreateDataset = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
const canConnectExternalDataset = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
|
||||
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
|
||||
const datasetListQuery = useDatasetList({
|
||||
initialPage: 1,
|
||||
@ -74,44 +65,6 @@ const List = () => {
|
||||
const hasActiveFilters = tagIDs.length > 0 || keywords.trim().length > 0 || searchKeywords.trim().length > 0 || includeAll
|
||||
const showEmptyDataList = !hasAnyDataset && (canCreateDataset || canConnectExternalDataset) && hasResolvedFirstPage && !hasActiveFilters
|
||||
const showFilteredEmptyState = !hasAnyDataset && hasResolvedFirstPage && hasActiveFilters
|
||||
const hasKnowledgeWalkthroughPermissions = canCreateDataset && canConnectExternalDataset
|
||||
const activeKnowledgeGuideGroup = hasKnowledgeWalkthroughPermissions
|
||||
? showEmptyDataList
|
||||
? 'knowledgeEmpty'
|
||||
: hasAnyDataset
|
||||
? 'knowledgeWithDatasets'
|
||||
: undefined
|
||||
: undefined
|
||||
const effectiveActiveKnowledgeGuideGroup = stepByStepTourAccountState.activeGuideGroup ?? activeKnowledgeGuideGroup
|
||||
const activeKnowledgeGuides = stepByStepTourAccountState.activeTaskId === 'knowledge' && effectiveActiveKnowledgeGuideGroup
|
||||
? getStepByStepTourGuides('knowledge', effectiveActiveKnowledgeGuideGroup)
|
||||
: []
|
||||
const activeKnowledgeGuide = activeKnowledgeGuides[stepByStepTourAccountState.activeGuideIndex ?? 0]
|
||||
const shouldOpenStepByStepTourCreateMenu = activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate
|
||||
const shouldOpenStepByStepTourDatasetCardActionMenu = activeKnowledgeGuide?.target === STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard
|
||||
|
||||
useEffect(() => {
|
||||
if (stepByStepTourAccountState.activeTaskId !== 'knowledge')
|
||||
return
|
||||
if (!hasResolvedFirstPage || !activeKnowledgeGuideGroup)
|
||||
return
|
||||
if (stepByStepTourAccountState.activeGuideGroup === activeKnowledgeGuideGroup)
|
||||
return
|
||||
|
||||
// Sync the active walkthrough branch into the tour storage owner after the
|
||||
// Knowledge list data resolves.
|
||||
// eslint-disable-next-line react/set-state-in-effect
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeGuideGroup: activeKnowledgeGuideGroup,
|
||||
activeGuideIndex: 0,
|
||||
})
|
||||
}, [
|
||||
activeKnowledgeGuideGroup,
|
||||
hasResolvedFirstPage,
|
||||
setStepByStepTourAccountState,
|
||||
stepByStepTourAccountState,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow flex-col overflow-y-auto bg-background-body">
|
||||
@ -134,9 +87,6 @@ const List = () => {
|
||||
onKeywordsChange={handleKeywordsChange}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
onTagsChange={handleTagsChange}
|
||||
stepByStepTourCreateMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
|
||||
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
|
||||
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
|
||||
/>
|
||||
<DatasetFirstEmptyState
|
||||
canConnectExternalDataset={canConnectExternalDataset}
|
||||
@ -162,9 +112,6 @@ const List = () => {
|
||||
onKeywordsChange={handleKeywordsChange}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
onTagsChange={handleTagsChange}
|
||||
stepByStepTourCreateMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourCreateMenu : undefined}
|
||||
stepByStepTourCreateMenuTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreate}
|
||||
stepByStepTourCreateMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsCreateMenu}
|
||||
/>
|
||||
<Datasets
|
||||
datasetList={datasetListQuery.data}
|
||||
@ -176,9 +123,6 @@ const List = () => {
|
||||
isLoading={datasetListQuery.isLoading}
|
||||
isPlaceholderData={datasetListQuery.isPlaceholderData}
|
||||
onOpenTagManagement={() => setShowTagManagementModal(true)}
|
||||
stepByStepTourActionMenuOpen={activeKnowledgeGuide ? shouldOpenStepByStepTourDatasetCardActionMenu : undefined}
|
||||
stepByStepTourActionMenuHighlightPart={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCardActionsMenu}
|
||||
stepByStepTourCardTarget={STEP_BY_STEP_TOUR_TARGETS.knowledgeWithDatasetsFirstCard}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -47,6 +47,17 @@ vi.mock('@/context/app-context', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: mockUserProfile,
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}), () => ({
|
||||
isRbacEnabled: false,
|
||||
}))
|
||||
})
|
||||
|
||||
const createMockDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
|
||||
@ -19,17 +19,29 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Mock app-context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({
|
||||
const mockAppContextState = vi.hoisted(() => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock app-context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => mockAppContextState.userProfile,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
|
||||
isRbacEnabled: false,
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock image uploader hooks for AppIconPicker
|
||||
vi.mock('@/app/components/base/image-uploader/hooks', () => ({
|
||||
useLocalFileUploader: () => ({
|
||||
|
||||
@ -23,6 +23,15 @@ vi.mock('@/context/app-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: [],
|
||||
}))
|
||||
})
|
||||
|
||||
const createDefaultMockDataset = (): DataSet => ({
|
||||
id: 'dataset-1',
|
||||
name: 'Test Dataset',
|
||||
|
||||
@ -8,15 +8,14 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
|
||||
import { useDatasetACLCapabilities } from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { getDatasetACLCapabilities } from '@/utils/permission'
|
||||
import { checkShowMultiModalTip } from '../../utils'
|
||||
|
||||
const DEFAULT_APP_ICON: IconInfo = {
|
||||
@ -30,16 +29,7 @@ export const useFormState = () => {
|
||||
const { t } = useTranslation()
|
||||
const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
|
||||
const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
|
||||
const currentUserId = useAppContextWithSelector(state => state.userProfile?.id)
|
||||
const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys)
|
||||
const datasetACLCapabilities = useMemo(
|
||||
() => getDatasetACLCapabilities(currentDataset?.permission_keys, {
|
||||
currentUserId,
|
||||
resourceMaintainer: currentDataset?.maintainer,
|
||||
workspacePermissionKeys,
|
||||
}),
|
||||
[currentDataset?.maintainer, currentDataset?.permission_keys, currentUserId, workspacePermissionKeys],
|
||||
)
|
||||
const datasetACLCapabilities = useDatasetACLCapabilities(currentDataset)
|
||||
const canEditSettings = datasetACLCapabilities.canEdit
|
||||
|
||||
// Basic form state
|
||||
|
||||
@ -4,17 +4,26 @@ import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import PermissionSelector from '../index'
|
||||
|
||||
// Mock app-context
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useSelector: () => ({
|
||||
const mockAppContextState = vi.hoisted(() => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Current User',
|
||||
email: 'current@example.com',
|
||||
avatar_url: '',
|
||||
role: 'owner',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
let mockIsRbacEnabled = false
|
||||
|
||||
vi.mock('@/app/components/datasets/hooks/use-dataset-access', async () => {
|
||||
const { createDatasetAccessHookMock } = await import('@/app/components/datasets/hooks/__tests__/mock-dataset-access')
|
||||
|
||||
return createDatasetAccessHookMock(() => mockAppContextState, () => ({
|
||||
isRbacEnabled: mockIsRbacEnabled,
|
||||
}))
|
||||
})
|
||||
|
||||
describe('PermissionSelector', () => {
|
||||
const mockMemberList: Member[] = [
|
||||
{ id: 'user-1', name: 'Current User', email: 'current@example.com', avatar: '', avatar_url: '', role: 'owner', roles: [], last_login_at: '', created_at: '', status: 'active' }!,
|
||||
@ -33,6 +42,7 @@ describe('PermissionSelector', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsRbacEnabled = false
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -409,6 +419,8 @@ describe('PermissionSelector', () => {
|
||||
})
|
||||
|
||||
it('should show access config hint and remain closed when RBAC is enabled', () => {
|
||||
mockIsRbacEnabled = true
|
||||
|
||||
renderWithSystemFeatures(<PermissionSelector {...defaultProps} />, {
|
||||
systemFeatures: {
|
||||
rbac_enabled: true,
|
||||
|
||||
@ -7,12 +7,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import {
|
||||
useDatasetCurrentUser,
|
||||
useDatasetRbacEnabled,
|
||||
} from '@/app/components/datasets/hooks/use-dataset-access'
|
||||
import { DatasetPermission } from '@/models/datasets'
|
||||
import MemberItem from './member-item'
|
||||
import Item from './permission-item'
|
||||
@ -35,8 +36,8 @@ const PermissionSelector = ({
|
||||
onMemberSelect,
|
||||
}: RoleSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAppContextWithSelector(state => state.userProfile)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const userProfile = useDatasetCurrentUser()
|
||||
const isRbacEnabled = useDatasetRbacEnabled()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
@ -89,7 +90,7 @@ const PermissionSelector = ({
|
||||
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
|
||||
const isPartialMembers = permission === DatasetPermission.partialMembers
|
||||
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
|
||||
const isDisabledByRBAC = systemFeatures.rbac_enabled
|
||||
const isDisabledByRBAC = isRbacEnabled
|
||||
const isDisabled = disabled || isDisabledByRBAC
|
||||
|
||||
return (
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
import type {
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
} from '@dify/contracts/api/console/onboarding/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { StepByStepTourAccountState, StepByStepTourUiState } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import {
|
||||
StepByStepTourTestStateObserver,
|
||||
StepByStepTourTestUiStateHydrator,
|
||||
} from '@/app/components/step-by-step-tour/__tests__/test-utils'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@ -44,107 +34,6 @@ let mockIsError = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
|
||||
const createState = (
|
||||
overrides: Partial<StepByStepTourStateResponse> = {},
|
||||
): StepByStepTourStateResponse => ({
|
||||
eligible: true,
|
||||
first_workspace_id: 'workspace-1',
|
||||
skipped: false,
|
||||
completed_task_ids: [],
|
||||
manually_enabled_workspace_ids: ['workspace-1'],
|
||||
manually_disabled_workspace_ids: [],
|
||||
updated_at: '2026-07-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
const createUiState = (
|
||||
overrides: Partial<StepByStepTourUiState> = {},
|
||||
): StepByStepTourUiState => ({
|
||||
activeGuideGroup: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideIndexes: undefined,
|
||||
activeTaskId: undefined,
|
||||
minimized: false,
|
||||
...overrides,
|
||||
})
|
||||
let state = createState()
|
||||
let uiState: StepByStepTourUiState = createUiState()
|
||||
let observedState: StepByStepTourAccountState | undefined
|
||||
const patchState = vi.fn(
|
||||
async ({ body }: { body: StepByStepTourStatePatchPayload }): Promise<StepByStepTourStateResponse> => {
|
||||
switch (body.action) {
|
||||
case 'complete_task':
|
||||
state = {
|
||||
...state,
|
||||
completed_task_ids: body.task_id && !state.completed_task_ids?.includes(body.task_id)
|
||||
? [...(state.completed_task_ids ?? []), body.task_id]
|
||||
: state.completed_task_ids,
|
||||
}
|
||||
break
|
||||
case 'uncomplete_task':
|
||||
state = {
|
||||
...state,
|
||||
completed_task_ids: (state.completed_task_ids ?? []).filter(taskId => taskId !== body.task_id),
|
||||
}
|
||||
break
|
||||
case 'skip':
|
||||
state = {
|
||||
...state,
|
||||
skipped: true,
|
||||
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
|
||||
}
|
||||
break
|
||||
case 'enable_current_workspace':
|
||||
state = {
|
||||
...state,
|
||||
skipped: false,
|
||||
manually_enabled_workspace_ids: Array.from(new Set([...(state.manually_enabled_workspace_ids ?? []), 'workspace-1'])),
|
||||
manually_disabled_workspace_ids: (state.manually_disabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
|
||||
}
|
||||
break
|
||||
case 'disable_current_workspace':
|
||||
state = {
|
||||
...state,
|
||||
manually_enabled_workspace_ids: (state.manually_enabled_workspace_ids ?? []).filter(id => id !== 'workspace-1'),
|
||||
manually_disabled_workspace_ids: Array.from(new Set([...(state.manually_disabled_workspace_ids ?? []), 'workspace-1'])),
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return state
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
get observedState() {
|
||||
return observedState
|
||||
},
|
||||
get state() {
|
||||
return state
|
||||
},
|
||||
get uiState() {
|
||||
return uiState
|
||||
},
|
||||
patchState,
|
||||
reset() {
|
||||
state = createState()
|
||||
uiState = createUiState()
|
||||
observedState = undefined
|
||||
patchState.mockClear()
|
||||
},
|
||||
setObservedState(nextState: StepByStepTourAccountState) {
|
||||
observedState = nextState
|
||||
},
|
||||
setState(overrides: Partial<StepByStepTourStateResponse> = {}) {
|
||||
state = createState(overrides)
|
||||
},
|
||||
setUiState(overrides: Partial<StepByStepTourUiState> = {}) {
|
||||
uiState = createUiState(overrides)
|
||||
},
|
||||
stateQueryKey,
|
||||
}
|
||||
})
|
||||
const toastMocks = vi.hoisted(() => {
|
||||
const record = vi.fn()
|
||||
const api = Object.assign(vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options })), {
|
||||
@ -223,24 +112,6 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
stepByStepTour: {
|
||||
state: {
|
||||
get: {
|
||||
queryKey: () => mockStepByStepTour.stateQueryKey,
|
||||
queryOptions: () => ({
|
||||
queryKey: mockStepByStepTour.stateQueryKey,
|
||||
queryFn: async () => mockStepByStepTour.state,
|
||||
}),
|
||||
},
|
||||
patch: {
|
||||
mutationOptions: () => ({
|
||||
mutationFn: mockStepByStepTour.patchState,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
explore: {
|
||||
apps: {
|
||||
get: {
|
||||
@ -319,27 +190,9 @@ vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../../try-app', () => ({
|
||||
default: ({
|
||||
canCreate = true,
|
||||
createButtonStepByStepTourTarget,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
canCreate?: boolean
|
||||
createButtonStepByStepTourTarget?: string
|
||||
onCreate: () => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
default: ({ onCreate, onClose }: { onCreate: () => void, onClose: () => void }) => (
|
||||
<div data-testid="try-app-panel">
|
||||
{canCreate && (
|
||||
<button
|
||||
data-testid="try-app-create"
|
||||
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
|
||||
onClick={onCreate}
|
||||
>
|
||||
create
|
||||
</button>
|
||||
)}
|
||||
<button data-testid="try-app-create" onClick={onCreate}>create</button>
|
||||
<button data-testid="try-app-close" onClick={onClose}>close</button>
|
||||
</div>
|
||||
),
|
||||
@ -430,11 +283,6 @@ const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({
|
||||
|
||||
const mockAppCreatePermission = (hasEditPermission: boolean) => {
|
||||
;(useAppContext as Mock).mockReturnValue({
|
||||
currentWorkspace: {
|
||||
id: 'workspace-1',
|
||||
name: 'Solar Studio',
|
||||
role: 'owner',
|
||||
},
|
||||
userProfile: { id: 'user-1' },
|
||||
workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [],
|
||||
})
|
||||
@ -468,7 +316,6 @@ const renderAppList = (
|
||||
queryClient.setQueryData(exploreAppListQueryKey, mockExploreData)
|
||||
if (options.enableExploreBanner && !mockBannersLoading)
|
||||
queryClient.setQueryData(exploreBannersQueryKey, mockBanners)
|
||||
queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state)
|
||||
|
||||
const mockFetchAppList = fetchAppList as unknown as Mock
|
||||
const mockFetchBanners = fetchBanners as unknown as Mock
|
||||
@ -496,12 +343,7 @@ const renderAppList = (
|
||||
|
||||
const Wrapped = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<SystemFeaturesWrapper>
|
||||
<StepByStepTourTestUiStateHydrator initialState={mockStepByStepTour.uiState}>
|
||||
<StepByStepTourTestStateObserver onChange={mockStepByStepTour.setObservedState} />
|
||||
{children}
|
||||
</StepByStepTourTestUiStateHydrator>
|
||||
</SystemFeaturesWrapper>
|
||||
<SystemFeaturesWrapper>{children}</SystemFeaturesWrapper>
|
||||
</JotaiProvider>
|
||||
)
|
||||
const rendered = renderWithNuqs(
|
||||
@ -539,7 +381,6 @@ describe('AppList', () => {
|
||||
mockIsLoading = false
|
||||
mockIsError = false
|
||||
mockConfig.isCloudEdition = false
|
||||
mockStepByStepTour.reset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@ -699,12 +540,7 @@ describe('AppList', () => {
|
||||
|
||||
renderAppList()
|
||||
|
||||
const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' })
|
||||
expect(learnDifyHeading).toBeInTheDocument()
|
||||
expect(learnDifyHeading.closest('section')).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.home,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'explore.learnDify.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Learn Workflow Basics')).toBeInTheDocument()
|
||||
expect(screen.getByText('Learn Agent Basics')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'explore.learnDify.moreTemplates' })).not.toBeInTheDocument()
|
||||
@ -822,8 +658,8 @@ describe('AppList', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
@ -858,8 +694,8 @@ describe('AppList', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
};
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
@ -871,237 +707,6 @@ describe('AppList', () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('learn-basic-1')
|
||||
})
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
skipRedirectOnSuccess: false,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should advance the Learn Dify tour to the create button after a lesson opens', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
|
||||
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('try-app-create')).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
|
||||
)
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBe('home')
|
||||
expect(state?.activeGuideIndex).toBe(1)
|
||||
expect(state?.completedTaskIds).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the Learn Dify tour when a no-create user opens a lesson detail', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
|
||||
renderAppList(false, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
|
||||
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('try-app-create')).not.toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBeUndefined()
|
||||
expect(state?.activeGuideIndex).toBeUndefined()
|
||||
expect(state?.activeGuideGroup).toBeUndefined()
|
||||
expect(state?.completedTaskIds).toEqual(['home'])
|
||||
expect(state?.minimized).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the Learn Dify tour only after the app is created from details', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
});
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBeUndefined()
|
||||
expect(state?.activeGuideIndex).toBeUndefined()
|
||||
expect(state?.completedTaskIds).toEqual(['home'])
|
||||
})
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
skipRedirectOnSuccess: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should skip redirect after confirming a pending Learn Dify tour create', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
});
|
||||
(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content', mode: AppModeEnum.CHAT })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: (payload: { app_mode: AppModeEnum }) => void }) => {
|
||||
options.onSuccess?.({ app_mode: AppModeEnum.CHAT })
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
fireEvent.click(await screen.findByTestId('dsl-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skipRedirectOnSuccess: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the Learn Dify tour target while the create modal is open and abandon on cancel', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
const createFromDetailsButton = await screen.findByTestId('try-app-create')
|
||||
expect(createFromDetailsButton).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
|
||||
)
|
||||
|
||||
fireEvent.click(createFromDetailsButton)
|
||||
expect(await screen.findByTestId('create-app-modal')).toBeInTheDocument()
|
||||
expect(createFromDetailsButton).not.toHaveAttribute('data-step-by-step-tour-target')
|
||||
|
||||
fireEvent.click(screen.getByTestId('hide-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBeUndefined()
|
||||
expect(state?.activeGuideIndex).toBeUndefined()
|
||||
expect(state?.completedTaskIds).toEqual([])
|
||||
})
|
||||
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should restart the Learn Dify tour from a clean state after abandoning create', async () => {
|
||||
vi.useRealTimers()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
const { unmount } = renderAppList(true, undefined, undefined, {
|
||||
isCloudEdition: true,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
fireEvent.click(await screen.findByTestId('try-app-create'))
|
||||
fireEvent.click(await screen.findByTestId('hide-create'))
|
||||
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBeUndefined()
|
||||
expect(state?.activeGuideIndex).toBeUndefined()
|
||||
})
|
||||
expect(screen.queryByTestId('try-app-panel')).not.toBeInTheDocument()
|
||||
|
||||
const abandonedTourState = mockStepByStepTour.observedState
|
||||
if (!abandonedTourState)
|
||||
throw new Error('Step-by-step tour state should be ready before restarting the home tour.')
|
||||
unmount()
|
||||
mockStepByStepTour.setState({
|
||||
active_task_id: 'home',
|
||||
active_guide_index: 0,
|
||||
completed_task_ids: abandonedTourState.completedTaskIds,
|
||||
first_workspace_id: abandonedTourState.firstWorkspaceId,
|
||||
manually_disabled_workspace_ids: abandonedTourState.manuallyDisabledWorkspaceIds,
|
||||
manually_enabled_workspace_ids: abandonedTourState.manuallyEnabledWorkspaceIds,
|
||||
minimized: true,
|
||||
skipped: abandonedTourState.skipped,
|
||||
})
|
||||
mockStepByStepTour.setUiState({
|
||||
activeTaskId: 'home',
|
||||
activeGuideIndex: 0,
|
||||
minimized: true,
|
||||
})
|
||||
|
||||
renderAppList(true, undefined, undefined, { isCloudEdition: true })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Learn Workflow Basics' }))
|
||||
|
||||
expect(await screen.findByTestId('try-app-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('try-app-create')).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate,
|
||||
)
|
||||
await waitFor(() => {
|
||||
const state = mockStepByStepTour.observedState
|
||||
expect(state?.activeTaskId).toBe('home')
|
||||
expect(state?.activeGuideIndex).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import ContinueWork from '@/app/components/explore/continue-work'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import dynamic from '@/next/dynamic'
|
||||
|
||||
const LearnDify = dynamic(() => import('@/app/components/explore/learn-dify'), { ssr: false })
|
||||
@ -12,13 +11,11 @@ const LearnDify = dynamic(() => import('@/app/components/explore/learn-dify'), {
|
||||
export function ExploreRecommendations({
|
||||
canCreate,
|
||||
continueWorkApps,
|
||||
forceShowLearnDify,
|
||||
onCreate,
|
||||
onTry,
|
||||
}: {
|
||||
canCreate: boolean
|
||||
continueWorkApps: WorkspaceApp[]
|
||||
forceShowLearnDify?: boolean
|
||||
onCreate: (app: App) => void
|
||||
onTry: (params: TryAppSelection) => void
|
||||
}) {
|
||||
@ -28,10 +25,8 @@ export function ExploreRecommendations({
|
||||
<LearnDify
|
||||
canCreate={canCreate}
|
||||
className="pb-0"
|
||||
forceVisible={forceShowLearnDify}
|
||||
onCreate={onCreate}
|
||||
onTry={onTry}
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { StepByStepTourPersistentState, StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
@ -18,20 +17,6 @@ import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-conf
|
||||
import AppCard from '@/app/components/explore/app-card'
|
||||
import Banner from '@/app/components/explore/banner/banner'
|
||||
import CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||
import {
|
||||
buildStepByStepTourScopedWorkspaceProperties,
|
||||
buildStepByStepTourWorkspaceProperties,
|
||||
getStepByStepTourPermissionVariant,
|
||||
STEP_BY_STEP_TOUR_ANALYTICS_EVENTS,
|
||||
trackStepByStepTourEvent,
|
||||
} from '@/app/components/step-by-step-tour/analytics'
|
||||
import { STEP_BY_STEP_TOUR_TASKS } from '@/app/components/step-by-step-tour/constants'
|
||||
import {
|
||||
useSetStepByStepTourAccountState,
|
||||
useStepByStepTourAccountStateValue,
|
||||
useStepByStepTourStateActions,
|
||||
} from '@/app/components/step-by-step-tour/storage'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
@ -64,7 +49,6 @@ const homeContinueWorkAppsInput = {
|
||||
}
|
||||
|
||||
const disabledBannersQueryKey = ['explore', 'home', 'banners', 'disabled'] as const
|
||||
const HOME_STEP_BY_STEP_TOUR_TASK_ID = 'home' satisfies StepByStepTourTaskId
|
||||
|
||||
function getLocaleQueryInput(locale?: string) {
|
||||
return locale
|
||||
@ -117,7 +101,7 @@ function getDisabledBannersQueryOptions() {
|
||||
const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const { currentWorkspace, workspacePermissionKeys } = useAppContext()
|
||||
const { workspacePermissionKeys } = useAppContext()
|
||||
const { data: systemFeatures } = useSuspenseQuery(
|
||||
systemFeaturesQueryOptions(),
|
||||
)
|
||||
@ -139,39 +123,6 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
})
|
||||
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
|
||||
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const stepByStepTourAccountState = useStepByStepTourAccountStateValue()
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour storage hooks are not React useState calls.
|
||||
const setStepByStepTourAccountState = useSetStepByStepTourAccountState()
|
||||
// eslint-disable-next-line react/use-state -- Step-by-step tour state actions are not React useState calls.
|
||||
const stepByStepTourActions = useStepByStepTourStateActions()
|
||||
const currentWorkspaceId = currentWorkspace.id
|
||||
const trackHomeTourCompleted = useCallback((state: StepByStepTourPersistentState) => {
|
||||
trackStepByStepTourEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENTS.taskCompleted, {
|
||||
...buildStepByStepTourWorkspaceProperties({ currentWorkspaceId }),
|
||||
task_id: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
||||
completed_task_count: state.completedTaskIds.length,
|
||||
completion_source: 'external_action',
|
||||
permission_variant: getStepByStepTourPermissionVariant({
|
||||
canCreateApp,
|
||||
hasIntegrationWalkthroughPermissions: true,
|
||||
hasKnowledgeWalkthroughPermissions: true,
|
||||
taskId: HOME_STEP_BY_STEP_TOUR_TASK_ID,
|
||||
}),
|
||||
task_total: STEP_BY_STEP_TOUR_TASKS.length,
|
||||
})
|
||||
|
||||
if (STEP_BY_STEP_TOUR_TASKS.every(task => state.completedTaskIds.includes(task.id))) {
|
||||
trackStepByStepTourEvent(STEP_BY_STEP_TOUR_ANALYTICS_EVENTS.completed, {
|
||||
...buildStepByStepTourScopedWorkspaceProperties({
|
||||
accountState: state,
|
||||
currentWorkspaceId,
|
||||
}),
|
||||
completed_task_ids: state.completedTaskIds,
|
||||
task_total: STEP_BY_STEP_TOUR_TASKS.length,
|
||||
})
|
||||
}
|
||||
}, [canCreateApp, currentWorkspaceId])
|
||||
|
||||
const [keywords, setKeywords] = useState('')
|
||||
const [searchKeywords, setSearchKeywords] = useState('')
|
||||
@ -247,125 +198,21 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
TrackCreateAppParams,
|
||||
'source' | 'templateId'
|
||||
> | null>(null)
|
||||
const isCurrentTryAppFromLearnDifyRef = useRef(false)
|
||||
const shouldCompleteHomeTourOnCreateRef = useRef(false)
|
||||
const isSubmittingHomeTourCreateRef = useRef(false)
|
||||
const isShowTryAppPanel = !!currentTryApp
|
||||
const shouldForceShowLearnDifyForTour = stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
|
||||
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
||||
&& (stepByStepTourAccountState.activeGuideIndex ?? 0) === 0
|
||||
const abandonHomeTour = useCallback(() => {
|
||||
if (
|
||||
stepByStepTourAccountState.activeTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID
|
||||
|| stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeTaskId: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideGroup: undefined,
|
||||
minimized: true,
|
||||
})
|
||||
}, [setStepByStepTourAccountState, stepByStepTourAccountState])
|
||||
|
||||
const completeHomeTourAfterCreate = useCallback(() => {
|
||||
if (!shouldCompleteHomeTourOnCreateRef.current)
|
||||
return
|
||||
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeTaskId: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideGroup: undefined,
|
||||
minimized: false,
|
||||
})
|
||||
stepByStepTourActions.completeTask(HOME_STEP_BY_STEP_TOUR_TASK_ID, {
|
||||
onSuccess: trackHomeTourCompleted,
|
||||
})
|
||||
isCurrentTryAppFromLearnDifyRef.current = false
|
||||
shouldCompleteHomeTourOnCreateRef.current = false
|
||||
isSubmittingHomeTourCreateRef.current = false
|
||||
}, [setStepByStepTourAccountState, stepByStepTourAccountState, stepByStepTourActions, trackHomeTourCompleted])
|
||||
|
||||
const completeHomeTourAfterOpenDetails = useCallback(() => {
|
||||
if (
|
||||
stepByStepTourAccountState.activeTaskId !== HOME_STEP_BY_STEP_TOUR_TASK_ID
|
||||
|| stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
||||
|| (stepByStepTourAccountState.activeGuideIndex ?? 0) !== 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeTaskId: undefined,
|
||||
activeGuideIndex: undefined,
|
||||
activeGuideGroup: undefined,
|
||||
minimized: false,
|
||||
})
|
||||
stepByStepTourActions.completeTask(HOME_STEP_BY_STEP_TOUR_TASK_ID, {
|
||||
onSuccess: trackHomeTourCompleted,
|
||||
})
|
||||
}, [setStepByStepTourAccountState, stepByStepTourAccountState, stepByStepTourActions, trackHomeTourCompleted])
|
||||
|
||||
const abandonHomeTourCreate = useCallback(() => {
|
||||
if (!isCurrentTryAppFromLearnDifyRef.current || isSubmittingHomeTourCreateRef.current)
|
||||
return
|
||||
|
||||
abandonHomeTour()
|
||||
setCurrentTryApp(undefined)
|
||||
setCurrApp(null)
|
||||
currentCreateAppTrackingRef.current = null
|
||||
currentCreateAppModeRef.current = null
|
||||
isCurrentTryAppFromLearnDifyRef.current = false
|
||||
shouldCompleteHomeTourOnCreateRef.current = false
|
||||
}, [abandonHomeTour])
|
||||
|
||||
const hideTryAppPanel = useCallback(() => {
|
||||
abandonHomeTourCreate()
|
||||
setCurrentTryApp(undefined)
|
||||
}, [abandonHomeTourCreate])
|
||||
}, [])
|
||||
const handleTryApp = useCallback((params: TryAppSelection) => {
|
||||
isCurrentTryAppFromLearnDifyRef.current = false
|
||||
setCurrentTryApp(params)
|
||||
}, [])
|
||||
const handleTryAppFromLearnDify = useCallback((params: TryAppSelection) => {
|
||||
isCurrentTryAppFromLearnDifyRef.current = true
|
||||
setCurrentTryApp(params)
|
||||
|
||||
if (
|
||||
stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
|
||||
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
||||
&& (stepByStepTourAccountState.activeGuideIndex ?? 0) === 0
|
||||
) {
|
||||
if (!canCreateApp) {
|
||||
completeHomeTourAfterOpenDetails()
|
||||
isCurrentTryAppFromLearnDifyRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setStepByStepTourAccountState({
|
||||
...stepByStepTourAccountState,
|
||||
activeGuideIndex: 1,
|
||||
minimized: true,
|
||||
})
|
||||
}
|
||||
}, [canCreateApp, completeHomeTourAfterOpenDetails, setStepByStepTourAccountState, stepByStepTourAccountState])
|
||||
const handleShowFromTryApp = useCallback(() => {
|
||||
setCurrApp(currentTryApp?.app || null)
|
||||
currentCreateAppTrackingRef.current = {
|
||||
source: 'explore_template_preview',
|
||||
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
|
||||
}
|
||||
shouldCompleteHomeTourOnCreateRef.current = isCurrentTryAppFromLearnDifyRef.current
|
||||
&& stepByStepTourAccountState.activeTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID
|
||||
&& !stepByStepTourAccountState.completedTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID)
|
||||
&& stepByStepTourAccountState.activeGuideIndex === 1
|
||||
setIsShowCreateModal(true)
|
||||
}, [currentTryApp?.app, currentTryApp?.appId, stepByStepTourAccountState])
|
||||
}, [currentTryApp?.app, currentTryApp?.appId])
|
||||
const handleCreateFromLearnDify = useCallback((app: App) => {
|
||||
setCurrApp(app)
|
||||
setIsShowCreateModal(true)
|
||||
@ -394,16 +241,9 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
},
|
||||
[],
|
||||
)
|
||||
const handleCreateModalHide = useCallback(() => {
|
||||
if (!isSubmittingHomeTourCreateRef.current)
|
||||
abandonHomeTourCreate()
|
||||
|
||||
setIsShowCreateModal(false)
|
||||
}, [abandonHomeTourCreate])
|
||||
|
||||
const onCreate: CreateAppModalProps['onConfirm'] = useCallback(
|
||||
async ({ name, icon_type, icon, icon_background, description }) => {
|
||||
isSubmittingHomeTourCreateRef.current = shouldCompleteHomeTourOnCreateRef.current
|
||||
hideTryAppPanel()
|
||||
|
||||
const appId = currApp?.app.id
|
||||
@ -421,44 +261,27 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
icon_background,
|
||||
description,
|
||||
}
|
||||
let didTransitionCreateFlow = false
|
||||
await handleImportDSL(payload, {
|
||||
onSuccess: (response) => {
|
||||
didTransitionCreateFlow = true
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
completeHomeTourAfterCreate()
|
||||
setIsShowCreateModal(false)
|
||||
},
|
||||
onPending: () => {
|
||||
didTransitionCreateFlow = true
|
||||
setShowDSLConfirmModal(true)
|
||||
},
|
||||
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
|
||||
})
|
||||
if (!didTransitionCreateFlow && shouldCompleteHomeTourOnCreateRef.current) {
|
||||
isSubmittingHomeTourCreateRef.current = false
|
||||
abandonHomeTourCreate()
|
||||
}
|
||||
},
|
||||
[abandonHomeTourCreate, completeHomeTourAfterCreate, currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp],
|
||||
[currApp?.app.id, handleImportDSL, hideTryAppPanel, trackCurrentCreateApp],
|
||||
)
|
||||
|
||||
const onConfirmDSL = useCallback(async () => {
|
||||
await handleImportDSLConfirm({
|
||||
onSuccess: (response) => {
|
||||
trackCurrentCreateApp(response.app_mode)
|
||||
completeHomeTourAfterCreate()
|
||||
onSuccess?.()
|
||||
},
|
||||
skipRedirectOnSuccess: shouldCompleteHomeTourOnCreateRef.current,
|
||||
})
|
||||
}, [completeHomeTourAfterCreate, handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
const handleCancelDSLConfirm = useCallback(() => {
|
||||
setShowDSLConfirmModal(false)
|
||||
isSubmittingHomeTourCreateRef.current = false
|
||||
abandonHomeTourCreate()
|
||||
}, [abandonHomeTourCreate])
|
||||
}, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp])
|
||||
|
||||
if (homeQueries.isAppListError)
|
||||
return null
|
||||
@ -482,9 +305,8 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
<ExploreRecommendations
|
||||
canCreate={canCreateApp}
|
||||
continueWorkApps={homeQueries.continueWorkApps}
|
||||
forceShowLearnDify={shouldForceShowLearnDifyForTour}
|
||||
onCreate={handleCreateFromLearnDify}
|
||||
onTry={handleTryAppFromLearnDify}
|
||||
onTry={handleTryApp}
|
||||
/>
|
||||
|
||||
<ExploreAppListHeader
|
||||
@ -528,13 +350,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
show={isShowCreateModal}
|
||||
onConfirm={onCreate}
|
||||
confirmDisabled={isFetching}
|
||||
onHide={handleCreateModalHide}
|
||||
onHide={() => setIsShowCreateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDSLConfirmModal && (
|
||||
<DSLConfirmModal
|
||||
versions={versions}
|
||||
onCancel={handleCancelDSLConfirm}
|
||||
onCancel={() => setShowDSLConfirmModal(false)}
|
||||
onConfirm={onConfirmDSL}
|
||||
confirmDisabled={isFetching}
|
||||
/>
|
||||
@ -544,13 +366,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
<TryApp
|
||||
appId={currentTryApp?.appId || ''}
|
||||
app={currentTryApp?.app}
|
||||
canCreate={canCreateApp}
|
||||
categories={currentTryApp?.app?.categories}
|
||||
createButtonStepByStepTourTarget={
|
||||
canCreateApp && isCurrentTryAppFromLearnDifyRef.current && !isShowCreateModal
|
||||
? STEP_BY_STEP_TOUR_TARGETS.homeTryAppCreate
|
||||
: undefined
|
||||
}
|
||||
onClose={hideTryAppPanel}
|
||||
onCreate={handleShowFromTryApp}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Banner } from '@/models/app'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BannerItem } from '../banner-item'
|
||||
|
||||
@ -37,11 +37,9 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
|
||||
|
||||
const mockResizeObserverObserve = vi.fn()
|
||||
const mockResizeObserverDisconnect = vi.fn()
|
||||
let mockResizeObserverCallbacks: ResizeObserverCallback[] = []
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
mockResizeObserverCallbacks.push(callback)
|
||||
constructor(_callback: ResizeObserverCallback) {
|
||||
}
|
||||
|
||||
observe(...args: Parameters<ResizeObserver['observe']>) {
|
||||
@ -71,9 +69,6 @@ const renderBannerItem = (
|
||||
)
|
||||
}
|
||||
|
||||
const getIndicatorButtons = () =>
|
||||
screen.queryAllByText(/^\d{2}$/).map(label => label.closest('button'))
|
||||
|
||||
describe('BannerItem', () => {
|
||||
let mockWindowOpen: ReturnType<typeof vi.spyOn>
|
||||
|
||||
@ -82,7 +77,6 @@ describe('BannerItem', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
mockResizeObserverCallbacks = []
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
@ -134,7 +128,8 @@ describe('BannerItem', () => {
|
||||
const banner = createMockBanner({ link: 'https://test-link.com' })
|
||||
renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Banner Title' }))
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
banner_id: 'banner-1',
|
||||
@ -157,7 +152,8 @@ describe('BannerItem', () => {
|
||||
const banner = createMockBanner({ link: '' })
|
||||
renderBannerItem(banner)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Test Banner Title' }))
|
||||
const bannerElement = screen.getByText('Test Banner Title').closest('div[class*="cursor-pointer"]')
|
||||
fireEvent.click(bannerElement!)
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('explore_banner_click', expect.objectContaining({
|
||||
link: '',
|
||||
@ -170,11 +166,7 @@ describe('BannerItem', () => {
|
||||
it('renders correct number of indicator buttons', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
expect(['01', '02', '03'].map(label => screen.getByText(label).closest('button'))).toEqual([
|
||||
expect.any(HTMLButtonElement),
|
||||
expect.any(HTMLButtonElement),
|
||||
expect.any(HTMLButtonElement),
|
||||
])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders indicator buttons with correct numbers', () => {
|
||||
@ -193,27 +185,12 @@ describe('BannerItem', () => {
|
||||
fireEvent.click(secondIndicator!)
|
||||
|
||||
expect(mockScrollTo).toHaveBeenCalledWith(1)
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate the banner when an indicator is used from the keyboard', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
|
||||
const indicatorButton = screen.getByText('02').closest('button')
|
||||
|
||||
fireEvent.keyDown(indicatorButton!, { key: 'Enter' })
|
||||
fireEvent.click(indicatorButton!)
|
||||
|
||||
expect(mockScrollTo).toHaveBeenCalledWith(1)
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders no indicators when no slides', () => {
|
||||
mockSlideNodes.mockReturnValue([])
|
||||
renderBannerItem()
|
||||
expect(getIndicatorButtons()).toHaveLength(0)
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -235,33 +212,6 @@ describe('BannerItem', () => {
|
||||
expect(mockResizeObserverObserve).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('schedules responsive width updates outside the ResizeObserver callback', () => {
|
||||
const animationFrameCallbacks: FrameRequestCallback[] = []
|
||||
const requestAnimationFrameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
|
||||
animationFrameCallbacks.push(callback)
|
||||
return animationFrameCallbacks.length
|
||||
})
|
||||
const cancelAnimationFrameSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
renderBannerItem()
|
||||
act(() => {
|
||||
animationFrameCallbacks.splice(0).forEach(callback => callback(0))
|
||||
})
|
||||
requestAnimationFrameSpy.mockClear()
|
||||
|
||||
act(() => {
|
||||
mockResizeObserverCallbacks[0]?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
finally {
|
||||
requestAnimationFrameSpy.mockRestore()
|
||||
cancelAnimationFrameSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('adds resize event listener on mount', () => {
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
|
||||
renderBannerItem()
|
||||
@ -373,20 +323,21 @@ describe('BannerItem', () => {
|
||||
it('calculates next index correctly for first slide', () => {
|
||||
mockSlideNodes.mockReturnValue([{}, {}, {}])
|
||||
renderBannerItem()
|
||||
expect(getIndicatorButtons()).toHaveLength(3)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('handles single slide case', () => {
|
||||
mockSlideNodes.mockReturnValue([{}])
|
||||
renderBannerItem()
|
||||
expect(getIndicatorButtons()).toHaveLength(1)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wrapper styling', () => {
|
||||
it('shows pointer affordance on the banner activation control', () => {
|
||||
renderBannerItem()
|
||||
expect(screen.getByRole('button', { name: 'Test Banner Title' })).toHaveClass('cursor-pointer')
|
||||
it('has cursor-pointer class', () => {
|
||||
const { container } = renderBannerItem()
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper).toHaveClass('cursor-pointer')
|
||||
})
|
||||
|
||||
it('has rounded-2xl class', () => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
/* eslint-disable react/set-state-in-effect */
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Banner } from '@/models/app'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -36,7 +35,6 @@ export function BannerItem({
|
||||
|
||||
const [resetKey, setResetKey] = useState(0)
|
||||
const textAreaRef = useRef<HTMLDivElement>(null)
|
||||
const maxWidthFrameRef = useRef<number | undefined>(undefined)
|
||||
const [maxWidth, setMaxWidth] = useState<number | undefined>(undefined)
|
||||
|
||||
const slideInfo = useMemo(() => {
|
||||
@ -78,44 +76,30 @@ export function BannerItem({
|
||||
|
||||
const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), [])
|
||||
|
||||
const updateMaxWidth = useCallback(() => {
|
||||
if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
|
||||
const textAreaWidth = textAreaRef.current.offsetWidth
|
||||
setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
|
||||
}
|
||||
else {
|
||||
setMaxWidth(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scheduleMaxWidthUpdate = useCallback(() => {
|
||||
if (maxWidthFrameRef.current !== undefined)
|
||||
return
|
||||
|
||||
maxWidthFrameRef.current = window.requestAnimationFrame(() => {
|
||||
maxWidthFrameRef.current = undefined
|
||||
updateMaxWidth()
|
||||
})
|
||||
}, [updateMaxWidth])
|
||||
|
||||
useEffect(() => {
|
||||
scheduleMaxWidthUpdate()
|
||||
const updateMaxWidth = () => {
|
||||
if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
|
||||
const textAreaWidth = textAreaRef.current.offsetWidth
|
||||
setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
|
||||
}
|
||||
else {
|
||||
setMaxWidth(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleMaxWidthUpdate)
|
||||
updateMaxWidth()
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateMaxWidth)
|
||||
if (textAreaRef.current)
|
||||
resizeObserver.observe(textAreaRef.current)
|
||||
|
||||
window.addEventListener('resize', scheduleMaxWidthUpdate)
|
||||
window.addEventListener('resize', updateMaxWidth)
|
||||
|
||||
return () => {
|
||||
if (maxWidthFrameRef.current !== undefined) {
|
||||
window.cancelAnimationFrame(maxWidthFrameRef.current)
|
||||
maxWidthFrameRef.current = undefined
|
||||
}
|
||||
resizeObserver.disconnect()
|
||||
window.removeEventListener('resize', scheduleMaxWidthUpdate)
|
||||
window.removeEventListener('resize', updateMaxWidth)
|
||||
}
|
||||
}, [scheduleMaxWidthUpdate])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
incrementResetKey()
|
||||
@ -143,22 +127,11 @@ export function BannerItem({
|
||||
if (banner.link)
|
||||
window.open(banner.link, '_blank', 'noopener,noreferrer')
|
||||
}, [accountId, banner, incrementResetKey, language, sort])
|
||||
const handleBannerKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ')
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
handleBannerClick()
|
||||
}, [handleBannerClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid xl:h-[184px]"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={title}
|
||||
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs xl:h-[184px]"
|
||||
onClick={handleBannerClick}
|
||||
onKeyDown={handleBannerKeyDown}
|
||||
>
|
||||
<div className="flex min-w-px flex-1 flex-col items-end self-stretch rounded-2xl py-6 pl-8">
|
||||
<div className="w-full min-w-0 pr-4" style={responsiveStyle}>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable react/set-state-in-effect */
|
||||
import type { FC, KeyboardEvent, MouseEvent } from 'react'
|
||||
/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
@ -64,23 +64,18 @@ export const IndicatorButton: FC<IndicatorButtonProps> = ({
|
||||
if (frameIdRef.current)
|
||||
cancelAnimationFrame(frameIdRef.current)
|
||||
}
|
||||
}, [isNextSlide, autoplayDelay, resetKey, isPaused, shouldAnimate])
|
||||
}, [isNextSlide, autoplayDelay, resetKey, isPaused])
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onClick()
|
||||
}, [onClick])
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ')
|
||||
event.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const progressDegrees = progress * DEGREES_PER_PERCENT
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'relative flex h-[18px] w-[20px] items-center justify-center rounded-[7px] border border-divider-subtle p-[2px] text-center system-2xs-semibold-uppercase transition-colors',
|
||||
isActive
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
import type { App } from '@/models/explore'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import LearnDify from '../index'
|
||||
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '../storage'
|
||||
|
||||
let mockLearnDifyApps: App[] = []
|
||||
let mockLearnDifyLoading = false
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useLearnDifyAppList: () => ({
|
||||
data: mockLearnDifyApps,
|
||||
isLoading: mockLearnDifyLoading,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '😀',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'Learn Dify App',
|
||||
description: overrides.app?.description ?? 'Learn Dify description',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
can_trial: overrides.can_trial ?? true,
|
||||
app_id: overrides.app_id ?? 'learn-dify-app',
|
||||
description: overrides.description ?? 'Learn Dify description',
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
categories: overrides.categories ?? ['Writing'],
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
installed: overrides.installed ?? false,
|
||||
editable: overrides.editable ?? false,
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const renderLearnDify = ({
|
||||
enableLearnApp = true,
|
||||
forceVisible = false,
|
||||
}: {
|
||||
enableLearnApp?: boolean
|
||||
forceVisible?: boolean
|
||||
} = {}) => {
|
||||
const { wrapper } = createSystemFeaturesWrapper({
|
||||
systemFeatures: {
|
||||
enable_learn_app: enableLearnApp,
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<LearnDify
|
||||
forceVisible={forceVisible}
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.home}
|
||||
/>,
|
||||
{ wrapper },
|
||||
)
|
||||
}
|
||||
|
||||
describe('LearnDify', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockLearnDifyApps = [createApp()]
|
||||
mockLearnDifyLoading = false
|
||||
})
|
||||
|
||||
it('should stay hidden when the user hidden preference is set', () => {
|
||||
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
|
||||
|
||||
renderLearnDify()
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show hidden content when forceVisible is set for the step tour', () => {
|
||||
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
|
||||
|
||||
renderLearnDify({ forceVisible: true })
|
||||
|
||||
const learnDifyHeading = screen.getByRole('heading', { name: 'explore.learnDify.title' })
|
||||
expect(learnDifyHeading).toBeInTheDocument()
|
||||
expect(learnDifyHeading.closest('section')).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.home,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'explore.learnDify.hide' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep Learn Dify hidden when the system feature is disabled', () => {
|
||||
localStorage.setItem(LEARN_DIFY_HIDDEN_STORAGE_KEY, 'true')
|
||||
|
||||
renderLearnDify({ enableLearnApp: false, forceVisible: true })
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'explore.learnDify.title' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -16,13 +16,11 @@ type LearnDifyProps = {
|
||||
canCreate?: boolean
|
||||
className?: string
|
||||
dismissible?: boolean
|
||||
forceVisible?: boolean
|
||||
itemLimit?: number
|
||||
loadingFallback?: React.ReactNode
|
||||
onCreate?: (app: App) => void
|
||||
onTry?: (params: TryAppSelection) => void
|
||||
showDescription?: boolean
|
||||
stepByStepTourTarget?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
@ -39,7 +37,6 @@ const LearnDifyContent = ({
|
||||
onCreate,
|
||||
onTry,
|
||||
showDescription = true,
|
||||
stepByStepTourTarget,
|
||||
title,
|
||||
}: LearnDifyContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
@ -96,7 +93,6 @@ const LearnDifyContent = ({
|
||||
)}
|
||||
style={isClosing ? { transform: collapseTransform, transformOrigin: 'center center' } : undefined}
|
||||
aria-labelledby="learn-dify-title"
|
||||
data-step-by-step-tour-target={stepByStepTourTarget}
|
||||
>
|
||||
<div className="-mx-4 rounded-2xl bg-background-section p-4">
|
||||
<div className="flex items-start justify-between gap-4 pb-2.5">
|
||||
@ -153,7 +149,7 @@ const LearnDify = (props: LearnDifyProps) => {
|
||||
if (!systemFeatures.enable_learn_app)
|
||||
return null
|
||||
|
||||
if (props.dismissible === false || props.forceVisible)
|
||||
if (props.dismissible === false)
|
||||
return <LearnDifyContent {...props} />
|
||||
|
||||
return <DismissibleLearnDify {...props} />
|
||||
|
||||
@ -12,10 +12,8 @@ import useGetRequirements from './use-get-requirements'
|
||||
type Props = Readonly<{
|
||||
appId: string
|
||||
appDetail: TryAppInfo
|
||||
canCreate?: boolean
|
||||
categories?: string[]
|
||||
className?: string
|
||||
createButtonStepByStepTourTarget?: string
|
||||
onCreate: () => void
|
||||
}>
|
||||
|
||||
@ -53,10 +51,8 @@ const RequirementIcon: FC<RequirementIconProps> = ({ iconUrl }) => {
|
||||
|
||||
const AppInfo: FC<Props> = ({
|
||||
appId,
|
||||
canCreate = true,
|
||||
className,
|
||||
categories,
|
||||
createButtonStepByStepTourTarget,
|
||||
appDetail,
|
||||
onCreate,
|
||||
}) => {
|
||||
@ -98,17 +94,10 @@ const AppInfo: FC<Props> = ({
|
||||
{appDetail.description && (
|
||||
<div className="mt-[14px] shrink-0 system-sm-regular text-text-secondary">{appDetail.description}</div>
|
||||
)}
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="mt-3 flex w-full max-w-full"
|
||||
data-step-by-step-tour-target={createButtonStepByStepTourTarget}
|
||||
onClick={onCreate}
|
||||
>
|
||||
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
|
||||
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
|
||||
<span className="mr-1 i-ri-add-line size-4 shrink-0" />
|
||||
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
|
||||
</Button>
|
||||
|
||||
{visibleCategories.length > 0 && (
|
||||
<div className="mt-6 shrink-0">
|
||||
|
||||
@ -22,9 +22,7 @@ import { TypeEnum } from './types'
|
||||
type Props = Readonly<{
|
||||
appId: string
|
||||
app?: AppType
|
||||
canCreate?: boolean
|
||||
categories?: string[]
|
||||
createButtonStepByStepTourTarget?: string
|
||||
onClose: () => void
|
||||
onCreate: () => void
|
||||
}>
|
||||
@ -32,9 +30,7 @@ type Props = Readonly<{
|
||||
const TryApp: FC<Props> = ({
|
||||
appId,
|
||||
app,
|
||||
canCreate = true,
|
||||
categories,
|
||||
createButtonStepByStepTourTarget,
|
||||
onClose,
|
||||
onCreate,
|
||||
}) => {
|
||||
@ -116,9 +112,7 @@ const TryApp: FC<Props> = ({
|
||||
className="w-[360px] shrink-0"
|
||||
appDetail={appDetail}
|
||||
appId={appId}
|
||||
canCreate={canCreate}
|
||||
categories={categories}
|
||||
createButtonStepByStepTourTarget={createButtonStepByStepTourTarget}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
export function Empty() {
|
||||
@ -7,10 +6,7 @@ export function Empty() {
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6"
|
||||
data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty}
|
||||
>
|
||||
<div className="mb-2 flex flex-col items-start gap-3 rounded-xl bg-background-section p-6">
|
||||
<div className="flex size-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg-alt shadow-lg backdrop-blur-xs">
|
||||
<span aria-hidden className="i-custom-vender-workflow-api-aggregate size-5 text-text-tertiary" />
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,6 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@ -135,17 +134,13 @@ export function ApiBasedExtensionPage({
|
||||
}
|
||||
{
|
||||
!isLoading && !!filteredApiBasedExtensions.length && (
|
||||
filteredApiBasedExtensions.map((item, index) => (
|
||||
<div
|
||||
filteredApiBasedExtensions.map(item => (
|
||||
<Item
|
||||
key={item.id}
|
||||
data-step-by-step-tour-target={index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationCustomEndpointEmpty : undefined}
|
||||
>
|
||||
<Item
|
||||
apiBasedExtension={item}
|
||||
onEdit={handleEditApiBasedExtension}
|
||||
canManage={canManage}
|
||||
/>
|
||||
</div>
|
||||
apiBasedExtension={item}
|
||||
onEdit={handleEditApiBasedExtension}
|
||||
canManage={canManage}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,7 +7,6 @@ import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/compone
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useGetDataSourceListAuth, useInvalidDataSourceListAuth } from '@/service/use-datasource'
|
||||
@ -126,7 +125,7 @@ const DataSourcePage = ({
|
||||
<>
|
||||
{isDataSourceListLoading && <DataSourceListSkeleton />}
|
||||
{!isDataSourceListLoading && !dataSources.length && (
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4" data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceFirstCard}>
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
|
||||
<span className="i-ri-database-2-line h-5 w-5 text-text-primary" />
|
||||
</div>
|
||||
@ -147,20 +146,16 @@ const DataSourcePage = ({
|
||||
{!isDataSourceListLoading && !!filteredDataSources.length && (
|
||||
<div className="space-y-2">
|
||||
{
|
||||
filteredDataSources.map((item, index) => {
|
||||
filteredDataSources.map((item) => {
|
||||
const pluginDetail = dataSourcePluginDetails.find(plugin => plugin.plugin_id === item.plugin_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={item.plugin_unique_identifier}
|
||||
data-step-by-step-tour-target={index === 0 ? STEP_BY_STEP_TOUR_TARGETS.integrationDataSourceFirstCard : undefined}
|
||||
>
|
||||
<Card
|
||||
item={item}
|
||||
pluginDetail={pluginDetail}
|
||||
onPluginUpdate={handlePluginUpdate}
|
||||
/>
|
||||
</div>
|
||||
item={item}
|
||||
pluginDetail={pluginDetail}
|
||||
onPluginUpdate={handlePluginUpdate}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
@ -515,16 +514,6 @@ describe('ModelProviderPage', () => {
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use the empty provider state as the production tour target when no provider cards exist', () => {
|
||||
mockProviders.splice(0)
|
||||
|
||||
renderModelProviderPage()
|
||||
|
||||
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction)
|
||||
const target = document.querySelector(selector)
|
||||
expect(target).toContainElement(screen.getByText('common.modelProvider.emptyProviderTitle'))
|
||||
})
|
||||
|
||||
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'langgenius/openai/openai',
|
||||
@ -679,9 +668,6 @@ describe('ModelProviderPage', () => {
|
||||
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
|
||||
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction)
|
||||
const target = document.querySelector(selector)
|
||||
expect(target).toContainElement(screen.getByText('anthropic'))
|
||||
})
|
||||
|
||||
it('should show none-configured warning when providers exist but no default models set', () => {
|
||||
|
||||
@ -3,7 +3,6 @@ import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getStepByStepTourTargetSelector, STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import InstallFromMarketplace from '../install-from-marketplace'
|
||||
|
||||
@ -114,34 +113,6 @@ describe('InstallFromMarketplace', () => {
|
||||
expect(screen.getByText('Plugin 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should anchor the tour target to the heading and first marketplace row', () => {
|
||||
(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({
|
||||
plugins: [
|
||||
{ plugin_id: '1', name: 'Plugin 1' },
|
||||
{ plugin_id: '2', name: 'Plugin 2' },
|
||||
{ plugin_id: '3', name: 'Plugin 3' },
|
||||
{ plugin_id: '4', name: 'Plugin 4' },
|
||||
],
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(
|
||||
<InstallFromMarketplace
|
||||
providers={mockProviders}
|
||||
searchText=""
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
|
||||
/>,
|
||||
)
|
||||
|
||||
const selector = getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall)
|
||||
const target = document.querySelector<HTMLElement>(selector)
|
||||
|
||||
expect(target).toHaveClass('absolute', 'inset-x-0', 'top-0', 'h-[174px]')
|
||||
expect(target).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(target?.parentElement).toContainElement(screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ }))
|
||||
expect(target?.parentElement).toContainElement(screen.getByTestId('plugin-list'))
|
||||
})
|
||||
|
||||
it('should hide bundle plugins from the list', () => {
|
||||
(useMarketplaceAllPlugins as unknown as Mock).mockReturnValue({
|
||||
plugins: [
|
||||
|
||||
@ -22,13 +22,11 @@ type InstallFromMarketplaceProps = {
|
||||
onOpenMarketplace?: () => void
|
||||
providers: ModelProvider[]
|
||||
searchText: string
|
||||
stepByStepTourTarget?: string
|
||||
}
|
||||
const InstallFromMarketplace = ({
|
||||
onOpenMarketplace,
|
||||
providers,
|
||||
searchText,
|
||||
stepByStepTourTarget,
|
||||
}: InstallFromMarketplaceProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
@ -49,63 +47,56 @@ const InstallFromMarketplace = ({
|
||||
return (
|
||||
<div id="model-provider-marketplace" className="flex scroll-mt-4 flex-col gap-2">
|
||||
<Divider className="my-2! h-px" />
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-[174px]"
|
||||
data-step-by-step-tour-target={stepByStepTourTarget}
|
||||
/>
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
|
||||
onClick={() => setCollapse(prev => !prev)}
|
||||
aria-expanded={!collapse}
|
||||
>
|
||||
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
|
||||
{t('modelProvider.installProvider', { ns: 'common' })}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
{onOpenMarketplace
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
className="inline-flex items-center system-sm-medium text-text-accent"
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-5 items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left system-md-semibold text-text-primary"
|
||||
onClick={() => setCollapse(prev => !prev)}
|
||||
aria-expanded={!collapse}
|
||||
>
|
||||
<span className={cn('i-ri-arrow-down-s-line size-4', collapse && '-rotate-90')} />
|
||||
{t('modelProvider.installProvider', { ns: 'common' })}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="system-sm-regular text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
{onOpenMarketplace
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center border-0 bg-transparent p-0 system-sm-medium text-text-accent"
|
||||
onClick={onOpenMarketplace}
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getMarketplaceCategoryUrl(PluginCategoryEnum.model, { theme })}
|
||||
className="inline-flex items-center system-sm-medium text-text-accent"
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<span className="i-ri-arrow-right-up-line size-4" aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type="area" />}
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton={canInstallPlugin}
|
||||
cardContainerClassName="grid grid-cols-3 gap-2"
|
||||
cardRender={cardRender}
|
||||
emptyClassName="h-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!collapse && isAllPluginsLoading && <Loading type="area" />}
|
||||
{
|
||||
!isAllPluginsLoading && !collapse && (
|
||||
<List
|
||||
marketplaceCollections={[]}
|
||||
marketplaceCollectionPluginsMap={{}}
|
||||
plugins={allPlugins}
|
||||
showInstallButton={canInstallPlugin}
|
||||
cardContainerClassName="grid grid-cols-3 gap-2"
|
||||
cardRender={cardRender}
|
||||
emptyClassName="h-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { PluginSource } from '@/app/components/plugins/types'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import ProviderAddedCard from './provider-added-card'
|
||||
@ -61,15 +60,13 @@ function ModelProviderListSkeleton() {
|
||||
|
||||
function EmptyProviderState({
|
||||
enableMarketplace,
|
||||
stepByStepTourTarget,
|
||||
}: {
|
||||
enableMarketplace: boolean
|
||||
stepByStepTourTarget?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="rounded-[10px] bg-workflow-process-bg p-4" data-step-by-step-tour-target={stepByStepTourTarget}>
|
||||
<div className="rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-sm">
|
||||
<span aria-hidden className="i-ri-brain-2-line size-5 text-text-primary" />
|
||||
</div>
|
||||
@ -86,9 +83,7 @@ function EmptyProviderState({
|
||||
href="#model-provider-marketplace"
|
||||
aria-label={t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
className="system-xs-medium text-text-accent hover:underline"
|
||||
>
|
||||
{t('mainNav.marketplace', { ns: 'common' })}
|
||||
</a>
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@ -100,7 +95,6 @@ function EmptyProviderState({
|
||||
}
|
||||
|
||||
type ProviderCardListProps = {
|
||||
firstCardTarget?: string
|
||||
providers: ModelProvider[]
|
||||
pluginDetailMap: Map<string, PluginDetail>
|
||||
notConfigured?: boolean
|
||||
@ -111,7 +105,6 @@ function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<strin
|
||||
}
|
||||
|
||||
function ProviderCardList({
|
||||
firstCardTarget,
|
||||
providers,
|
||||
pluginDetailMap,
|
||||
notConfigured,
|
||||
@ -129,20 +122,16 @@ function ProviderCardList({
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-2">
|
||||
{sortedProviders.map((provider, index) => {
|
||||
{sortedProviders.map((provider) => {
|
||||
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
|
||||
|
||||
return (
|
||||
<div
|
||||
<ProviderAddedCard
|
||||
key={provider.provider}
|
||||
data-step-by-step-tour-target={index === 0 ? firstCardTarget : undefined}
|
||||
>
|
||||
<ProviderAddedCard
|
||||
notConfigured={notConfigured}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetail}
|
||||
/>
|
||||
</div>
|
||||
notConfigured={notConfigured}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetail}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
@ -168,7 +157,7 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{IS_CLOUD_EDITION && (
|
||||
<div data-step-by-step-tour-target={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderCredits}>
|
||||
<div>
|
||||
<QuotaPanel providers={providers} />
|
||||
</div>
|
||||
)}
|
||||
@ -177,15 +166,9 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
<ModelProviderListSkeleton />
|
||||
</div>
|
||||
)}
|
||||
{showEmptyProvider && (
|
||||
<EmptyProviderState
|
||||
enableMarketplace={enableMarketplace}
|
||||
stepByStepTourTarget={!showConfiguredProviders && !showNotConfiguredProviders ? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction : undefined}
|
||||
/>
|
||||
)}
|
||||
{showEmptyProvider && <EmptyProviderState enableMarketplace={enableMarketplace} />}
|
||||
{showConfiguredProviders && (
|
||||
<ProviderCardList
|
||||
firstCardTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction}
|
||||
providers={filteredConfiguredProviders}
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
/>
|
||||
@ -194,7 +177,6 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||
<ProviderCardList
|
||||
firstCardTarget={!showConfiguredProviders ? STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderProduction : undefined}
|
||||
providers={filteredNotConfiguredProviders}
|
||||
notConfigured
|
||||
pluginDetailMap={pluginDetailMap}
|
||||
@ -207,7 +189,6 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
onOpenMarketplace={onOpenMarketplace}
|
||||
stepByStepTourTarget={STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderInstall}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user