mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 21:27:43 +08:00
Compare commits
3 Commits
fix/can-no
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a18d7f51eb | |||
| 680ef077ae | |||
| c26be9d3f4 |
@ -70,6 +70,12 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
|
||||
if role != TenantAccountRole.DATASET_OPERATOR:
|
||||
return True
|
||||
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
class MemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
@ -110,6 +116,8 @@ class MemberInviteEmailApi(Resource):
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
|
||||
# Check workspace permission for member invitations
|
||||
from libs.workspace_permission import check_workspace_member_invite_permission
|
||||
@ -208,6 +216,8 @@ class MemberUpdateRoleApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(new_role, current_user.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
abort(404)
|
||||
@ -215,11 +225,17 @@ class MemberUpdateRoleApi(Resource):
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, 400
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, 403
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, 404
|
||||
except services.errors.account.RoleAlreadyAssignedError as e:
|
||||
return {"code": "role-already-assigned", "message": str(e)}, 400
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
# todo: 403
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
|
||||
@ -1280,8 +1280,8 @@ class TenantService:
|
||||
"""Check member permission"""
|
||||
perms = {
|
||||
"add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"remove": [TenantAccountRole.OWNER],
|
||||
"update": [TenantAccountRole.OWNER],
|
||||
"remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
}
|
||||
if action not in {"add", "remove", "update"}:
|
||||
raise InvalidActionError("Invalid action.")
|
||||
@ -1299,6 +1299,15 @@ class TenantService:
|
||||
if not ta_operator or ta_operator.role not in perms[action]:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member:
|
||||
ta_member = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
|
||||
.limit(1)
|
||||
)
|
||||
if ta_member and ta_member.role == TenantAccountRole.OWNER:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
|
||||
"""Remove member from tenant.
|
||||
@ -1370,6 +1379,7 @@ class TenantService:
|
||||
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
|
||||
"""Update member role"""
|
||||
TenantService.check_member_permission(tenant, operator, member, "update")
|
||||
new_tenant_role = TenantAccountRole(new_role)
|
||||
|
||||
target_member_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
@ -1380,6 +1390,11 @@ class TenantService:
|
||||
if not target_member_join:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
operator_role = TenantService.get_user_role(operator, tenant)
|
||||
target_role = TenantAccountRole(target_member_join.role)
|
||||
if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}):
|
||||
raise NoPermissionError("No permission to update member.")
|
||||
|
||||
if target_member_join.role == new_role:
|
||||
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
|
||||
|
||||
@ -1394,7 +1409,7 @@ class TenantService:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
|
||||
# Update the role of the target member
|
||||
target_member_join.role = TenantAccountRole(new_role)
|
||||
target_member_join.role = new_tenant_role
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -13,6 +13,7 @@ from services.errors.account import (
|
||||
AccountPasswordError,
|
||||
AccountRegisterError,
|
||||
CurrentPasswordIncorrectError,
|
||||
NoPermissionError,
|
||||
)
|
||||
|
||||
|
||||
@ -817,8 +818,8 @@ class TestTenantService:
|
||||
|
||||
# Mock the database queries in update_member_role method
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
# scalar calls: permission check, target member lookup
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join]
|
||||
# scalar calls: permission check, target member lookup, operator role lookup
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
# Execute test
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
|
||||
@ -827,6 +828,65 @@ class TestTenantService:
|
||||
assert mock_target_join.role == "admin"
|
||||
self._assert_database_operations_called(mock_db)
|
||||
|
||||
def test_admin_can_update_admin_member_role(self):
|
||||
"""Test admin can update another non-owner member, including an admin."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
|
||||
|
||||
assert mock_target_join.role == "editor"
|
||||
self._assert_database_operations_called(mock_db)
|
||||
|
||||
def test_admin_cannot_update_owner_member_role(self):
|
||||
"""Test admin cannot update an owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="owner"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "editor", mock_operator)
|
||||
|
||||
def test_admin_cannot_promote_member_to_owner(self):
|
||||
"""Test admin cannot promote a non-owner member to owner."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
|
||||
with patch("services.account_service.db") as mock_db:
|
||||
mock_db.session.scalar.side_effect = [mock_operator_join, mock_target_join, mock_operator_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.update_member_role(mock_tenant, mock_member, "owner", mock_operator)
|
||||
|
||||
# ==================== Permission Check Tests ====================
|
||||
|
||||
def test_check_member_permission_success(self, mock_db_dependencies):
|
||||
@ -864,6 +924,39 @@ class TestTenantService:
|
||||
"add",
|
||||
)
|
||||
|
||||
def test_admin_can_remove_non_owner_member(self, mock_db_dependencies):
|
||||
"""Test admin can remove a non-owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="admin"
|
||||
)
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
|
||||
|
||||
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
|
||||
|
||||
def test_admin_cannot_remove_owner_member(self, mock_db_dependencies):
|
||||
"""Test admin cannot remove an owner member."""
|
||||
mock_tenant = MagicMock()
|
||||
mock_tenant.id = "tenant-456"
|
||||
mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
|
||||
mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
|
||||
mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="operator-123", role="admin"
|
||||
)
|
||||
mock_member_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
|
||||
tenant_id="tenant-456", account_id="member-789", role="owner"
|
||||
)
|
||||
mock_db_dependencies["db"].session.scalar.side_effect = [mock_operator_join, mock_member_join]
|
||||
|
||||
with pytest.raises(NoPermissionError):
|
||||
TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "remove")
|
||||
|
||||
|
||||
class TestRegisterService:
|
||||
"""
|
||||
|
||||
@ -79,6 +79,9 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({
|
||||
data: [],
|
||||
}),
|
||||
useInfiniteQuery: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
|
||||
@ -0,0 +1,151 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import DatasetDetailLayout from '../layout-main'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
useRouter: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/knowledge/use-dataset', () => ({
|
||||
useDatasetDetail: vi.fn(),
|
||||
useDatasetRelatedApps: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: undefined,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: () => <aside aria-label="dataset navigation" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/datasets/extra-info', () => ({
|
||||
default: () => <div />,
|
||||
}))
|
||||
|
||||
const mockUsePathname = vi.mocked(usePathname)
|
||||
const mockUseRouter = vi.mocked(useRouter)
|
||||
const mockUseDatasetDetail = vi.mocked(useDatasetDetail)
|
||||
const mockUseDatasetRelatedApps = vi.mocked(useDatasetRelatedApps)
|
||||
|
||||
describe('DatasetDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUsePathname.mockReturnValue('/datasets/dataset-1/pipeline')
|
||||
mockUseRouter.mockReturnValue({
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: mockReplace,
|
||||
prefetch: vi.fn(),
|
||||
})
|
||||
mockUseDatasetRelatedApps.mockReturnValue({ data: undefined } as ReturnType<typeof useDatasetRelatedApps>)
|
||||
})
|
||||
|
||||
describe('Access Errors', () => {
|
||||
it.each([403, 404])('should redirect to datasets page when dataset detail returns %s', async (status) => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
error: new Response(null, { status }),
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
expect(mockUseDatasetRelatedApps).toHaveBeenCalledWith('dataset-1', { enabled: false })
|
||||
expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should redirect when the dataset detail error exposes status without being a Response', async () => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
error: { status: 403 },
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
expect(screen.queryByText('Pipeline content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children when dataset detail is available', () => {
|
||||
// Arrange
|
||||
mockUseDatasetDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'dataset-1',
|
||||
name: 'Dataset 1',
|
||||
provider: 'vendor',
|
||||
runtime_mode: 'rag_pipeline',
|
||||
is_published: true,
|
||||
},
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDatasetDetail>)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<DatasetDetailLayout datasetId="dataset-1">
|
||||
<div>Pipeline content</div>
|
||||
</DatasetDetailLayout>,
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Pipeline content')).toBeInTheDocument()
|
||||
expect(mockUseDatasetRelatedApps).toHaveBeenCalledWith('dataset-1', { enabled: true })
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -23,7 +23,7 @@ import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
|
||||
type IAppDetailLayoutProps = {
|
||||
@ -31,12 +31,26 @@ type IAppDetailLayoutProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const getResponseStatus = (error: unknown) => {
|
||||
if (error instanceof Response)
|
||||
return error.status
|
||||
|
||||
if (typeof error === 'object' && error && 'status' in error && typeof error.status === 'number')
|
||||
return error.status
|
||||
}
|
||||
|
||||
const shouldRedirectToDatasetList = (error: unknown) => {
|
||||
const status = getResponseStatus(error)
|
||||
return status === 403 || status === 404
|
||||
}
|
||||
|
||||
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
datasetId,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
@ -54,8 +68,9 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
const isMobile = media === MediaType.mobile
|
||||
|
||||
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
|
||||
const shouldRedirect = shouldRedirectToDatasetList(error)
|
||||
|
||||
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
|
||||
const { data: relatedApps } = useDatasetRelatedApps(datasetId, { enabled: !!datasetRes && !shouldRedirect })
|
||||
|
||||
const isButtonDisabledWithPipeline = useMemo(() => {
|
||||
if (!datasetRes)
|
||||
@ -115,9 +130,17 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirect)
|
||||
router.replace('/datasets')
|
||||
}, [router, shouldRedirect])
|
||||
|
||||
if (!datasetRes && !error)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (shouldRedirect)
|
||||
return <Loading type="app" />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
@apply flex items-center place-content-between h-8;
|
||||
}
|
||||
@ -15,7 +19,7 @@
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
.tip {
|
||||
.modal .tip {
|
||||
@apply mt-1 mb-8 text-text-tertiary;
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
|
||||
@ -53,7 +53,7 @@ const EmptyDatasetCreationModal = ({ show = false, onHide }: IProps) => {
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-full max-w-[520px]! overflow-hidden! border-none px-8 text-left align-middle">
|
||||
<DialogContent className={cn('w-full overflow-hidden! border-none text-left align-middle', cn(s.modal, '!max-w-[520px]', 'px-8'))}>
|
||||
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.title}>{t('stepOne.modal.title', { ns: 'datasetCreation' })}</div>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
@reference "../../../../styles/globals.css";
|
||||
|
||||
.icon {
|
||||
.modal {
|
||||
position: relative;
|
||||
}
|
||||
.modal .icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.9) center no-repeat url(../assets/annotation-info.svg);
|
||||
@ -9,7 +12,7 @@
|
||||
box-shadow: 0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.close {
|
||||
.modal .close {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
@ -20,14 +23,14 @@
|
||||
background-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
.modal .title {
|
||||
@apply mt-3 mb-1;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
line-height: 30px;
|
||||
color: #101828;
|
||||
}
|
||||
.content {
|
||||
.modal .content {
|
||||
@apply mb-10;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
|
||||
@ -39,7 +39,7 @@ const StopEmbeddingModal = ({
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl">
|
||||
<AlertDialogContent className={cn(s.modal, 'max-w-[480px]! overflow-hidden! border-none px-8 py-6 text-left align-middle shadow-xl')}>
|
||||
<div className={s.icon} />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -52,7 +52,13 @@ vi.mock('../invited-modal', () => ({
|
||||
),
|
||||
}))
|
||||
vi.mock('../operation', () => ({
|
||||
default: () => <div>Member Operation</div>,
|
||||
default: ({ member }: { member: Member }) => (
|
||||
<div>
|
||||
Member Operation
|
||||
{' '}
|
||||
{member.role}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('../operation/transfer-ownership', () => ({
|
||||
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
|
||||
@ -296,6 +302,37 @@ describe('MembersPage', () => {
|
||||
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow admins to operate other non-owner members only', () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
userProfile: { email: 'admin@example.com' },
|
||||
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
|
||||
isCurrentWorkspaceOwner: false,
|
||||
isCurrentWorkspaceManager: true,
|
||||
} as unknown as AppContextValue)
|
||||
vi.mocked(useMembers).mockReturnValue({
|
||||
data: {
|
||||
accounts: [
|
||||
mockAccounts[0],
|
||||
mockAccounts[1],
|
||||
{ ...mockAccounts[1]!, id: '3', email: 'editor@example.com', name: 'Editor User', role: 'editor' },
|
||||
{ ...mockAccounts[1]!, id: '4', email: 'normal@example.com', name: 'Normal User', role: 'normal' },
|
||||
{ ...mockAccounts[1]!, id: '5', email: 'dataset@example.com', name: 'Dataset User', role: 'dataset_operator' },
|
||||
{ ...mockAccounts[1]!, id: '6', email: 'other-admin@example.com', name: 'Other Admin User', role: 'admin' },
|
||||
],
|
||||
},
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useMembers>)
|
||||
|
||||
renderMembersPage()
|
||||
|
||||
expect(screen.getByText('Member Operation editor'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation normal'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation dataset_operator'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('Member Operation admin'))!.toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.members.admin')).toHaveLength(1)
|
||||
expect(screen.queryByText('Member Operation owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use created_at as fallback when last_active_at is empty', () => {
|
||||
const memberNoLastActive: Member = {
|
||||
...mockAccounts[1]!,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import type { InvitationResult, Member } from '@/models/common'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@ -47,6 +47,12 @@ const MembersPage = () => {
|
||||
const isMemberFull = enableBilling && isNotUnlimitedMemberPlan && accounts.length >= plan.total.teamMembers
|
||||
const [editWorkspaceModalVisible, setEditWorkspaceModalVisible] = useState(false)
|
||||
const [showTransferOwnershipModal, setShowTransferOwnershipModal] = useState(false)
|
||||
const canOperateMember = (account: Member) => {
|
||||
if (isCurrentWorkspaceOwner)
|
||||
return account.role !== 'owner'
|
||||
|
||||
return currentWorkspace.role === 'admin' && account.role !== 'owner' && account.email !== userProfile.email
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -146,10 +152,10 @@ const MembersPage = () => {
|
||||
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
|
||||
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
|
||||
{account.role !== 'owner' && canOperateMember(account) && (
|
||||
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
|
||||
)}
|
||||
{!isCurrentWorkspaceOwner && (
|
||||
{account.role !== 'owner' && !canOperateMember(account) && (
|
||||
<div className="px-3 system-sm-regular text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -8,8 +8,8 @@ const mockUpdateMemberRole = vi.fn()
|
||||
const mockDeleteMemberOrCancelInvitation = vi.fn()
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
|
||||
updateMemberRole: () => mockUpdateMemberRole(),
|
||||
deleteMemberOrCancelInvitation: (args: unknown) => mockDeleteMemberOrCancelInvitation(args),
|
||||
updateMemberRole: (args: unknown) => mockUpdateMemberRole(args),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn(() => ({
|
||||
@ -65,18 +65,21 @@ describe('Operation', () => {
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show owner-allowed role options when operator role is admin', async () => {
|
||||
it('should show admin-allowed role options when operator role is admin', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'admin')
|
||||
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('common.members.editor')).toHaveLength(2)
|
||||
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show role options when operator role is unsupported', async () => {
|
||||
it('should not show role options or remove action when operator role is unsupported', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderOperation({}, 'normal')
|
||||
@ -84,7 +87,7 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.removeFromTeam')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call updateMemberRole and onOperate when selecting another role', async () => {
|
||||
@ -96,7 +99,10 @@ describe('Operation', () => {
|
||||
await user.click(await screen.findByText('common.members.normal'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalledWith({
|
||||
url: '/workspaces/current/members/member-id/update-role',
|
||||
body: { role: 'normal' },
|
||||
})
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -109,7 +115,7 @@ describe('Operation', () => {
|
||||
await user.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.members.admin')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to normal role label when member role is unknown', () => {
|
||||
@ -127,7 +133,9 @@ describe('Operation', () => {
|
||||
await user.click(await screen.findByText('common.members.removeFromTeam'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
|
||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalledWith({
|
||||
url: '/workspaces/current/members/member-id',
|
||||
})
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -26,6 +26,9 @@ const roleI18nKeyMap = {
|
||||
dataset_operator: { label: 'members.datasetOperator', tip: 'members.datasetOperatorTip' },
|
||||
} as const
|
||||
type OperationRoleKey = keyof typeof roleI18nKeyMap
|
||||
const nonOwnerRoles = ['admin', 'editor', 'normal'] as const
|
||||
const isNonOwnerRole = (role: Member['role']) => role !== 'owner'
|
||||
|
||||
const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
@ -48,13 +51,13 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
}
|
||||
if (operatorRole === 'admin') {
|
||||
return [
|
||||
'editor',
|
||||
'normal',
|
||||
...nonOwnerRoles,
|
||||
...(datasetOperatorEnabled ? ['dataset_operator'] as const : []),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}, [operatorRole, datasetOperatorEnabled])
|
||||
const canRemoveMember = operatorRole === 'owner' || (operatorRole === 'admin' && isNonOwnerRole(member.role))
|
||||
const handleDeleteMemberOrCancelInvitation = async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
@ -81,7 +84,7 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
|
||||
render={<button type="button" className={cn('group flex h-full w-full cursor-pointer items-center justify-between border-none bg-transparent px-3 text-left system-sm-regular text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')} />}
|
||||
>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<span aria-hidden className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
@ -108,19 +111,23 @@ const Operation = ({ member, operatorRole, onOperate }: IOperationProps) => {
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={handleDeleteMemberOrCancelInvitation}
|
||||
>
|
||||
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
{canRemoveMember && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="my-0" />
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
className="h-auto items-start gap-2 rounded-lg px-3 py-2"
|
||||
onClick={handleDeleteMemberOrCancelInvitation}
|
||||
>
|
||||
<span aria-hidden className="mt-[2px] h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
@ -57,7 +57,7 @@ const InstallBundle: FC<Props> = ({
|
||||
foldAnimInto()
|
||||
}}
|
||||
>
|
||||
<DialogContent className={cn('relative w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogContent className={cn('w-full max-w-[480px] overflow-hidden! text-left align-middle', cn(modalClassName, 'shadows-shadow-xl flex min-w-[560px] flex-col items-start rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0'))}>
|
||||
<DialogCloseButton />
|
||||
|
||||
<div className="flex items-start gap-2 self-stretch pt-6 pr-14 pb-3 pl-6">
|
||||
|
||||
@ -77,7 +77,7 @@ const PublishAsKnowledgePipelineModal = ({
|
||||
return (
|
||||
<>
|
||||
<Dialog open>
|
||||
<DialogContent className="relative w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
|
||||
<DialogContent className="w-full max-w-[480px]! overflow-hidden! border-none p-0! text-left align-middle">
|
||||
|
||||
<div className="relative flex items-center p-6 pr-14 pb-3 title-2xl-semi-bold text-text-primary">
|
||||
{t('common.publishAs', { ns: 'pipeline' })}
|
||||
|
||||
@ -480,7 +480,9 @@ describe('publisher', () => {
|
||||
it('should show publish as knowledge pipeline modal when permitted', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -495,7 +497,9 @@ describe('publisher', () => {
|
||||
it('should close publish as knowledge pipeline modal when cancel is clicked', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockIsAllowPublishAsCustomKnowledgePipelineTemplate.mockReturnValue(true)
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -516,7 +520,9 @@ describe('publisher', () => {
|
||||
it('should call publishAsCustomizedPipeline when confirm is clicked in modal', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -538,6 +544,35 @@ describe('publisher', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish as template with empty pipeline id fallback', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPipelineId.mockReturnValue(undefined as unknown as string)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
)
|
||||
fireEvent.click(publishAsButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('publish-as-knowledge-pipeline-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('modal-confirm'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: '',
|
||||
name: 'Test Pipeline',
|
||||
icon_info: { type: 'emoji', emoji: '📚', background: '#fff' },
|
||||
description: 'Test description',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Calls and Async Operations', () => {
|
||||
@ -607,7 +642,9 @@ describe('publisher', () => {
|
||||
it('should show success notification for publish as template', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -633,7 +670,9 @@ describe('publisher', () => {
|
||||
it('should invalidate customized template list after publish as template', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -686,7 +725,9 @@ describe('publisher', () => {
|
||||
it('should show error notification when publish as template fails', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -710,7 +751,9 @@ describe('publisher', () => {
|
||||
it('should close modal after publish as template error', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockRejectedValue(new Error('Template publish failed'))
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
@ -1051,7 +1094,9 @@ describe('publisher', () => {
|
||||
it('should complete full publish as template flow', async () => {
|
||||
mockPublishedAt.mockReturnValue(1700000000)
|
||||
mockPublishAsCustomizedPipeline.mockResolvedValue({})
|
||||
renderWithQueryClient(<Popup />)
|
||||
renderWithQueryClient(<Publisher />)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
const publishAsButton = screen.getAllByRole('button').find(btn =>
|
||||
btn.textContent?.includes('pipeline.common.publishAs'),
|
||||
|
||||
@ -327,11 +327,18 @@ describe('Popup', () => {
|
||||
|
||||
it('should request closing the outer popover before opening publish-as modal', () => {
|
||||
const onRequestClose = vi.fn()
|
||||
render(<Popup onRequestClose={onRequestClose} />)
|
||||
const onShowPublishAsKnowledgePipelineModal = vi.fn()
|
||||
render(
|
||||
<Popup
|
||||
onRequestClose={onRequestClose}
|
||||
onShowPublishAsKnowledgePipelineModal={onShowPublishAsKnowledgePipelineModal}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
|
||||
|
||||
expect(onRequestClose).toHaveBeenCalledTimes(1)
|
||||
expect(onShowPublishAsKnowledgePipelineModal).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -352,27 +359,6 @@ describe('Popup', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish params', () => {
|
||||
it('should publish as template with empty pipeline id fallback', async () => {
|
||||
mockPipelineId = undefined
|
||||
mockUseBoolean
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce(() => [true, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
.mockImplementationOnce((initial: boolean) => [initial, { setFalse: vi.fn(), setTrue: vi.fn() }])
|
||||
render(<Popup />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('publish-as-confirm'))
|
||||
|
||||
expect(mockPublishAsCustomizedPipeline).toHaveBeenCalledWith({
|
||||
pipelineId: '',
|
||||
name: 'My Pipeline',
|
||||
icon_info: { icon_type: 'emoji' },
|
||||
description: 'desc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Time formatting', () => {
|
||||
it('should format published time', () => {
|
||||
render(<Popup />)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
@ -10,6 +12,11 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import Link from '@/next/link'
|
||||
import { useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
|
||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||
import Popup from './popup'
|
||||
|
||||
const Publisher = () => {
|
||||
@ -17,6 +24,12 @@ const Publisher = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirmVisible, { setFalse: hideConfirm, setTrue: showConfirm }] = useBoolean(false)
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const docLink = useDocLink()
|
||||
const pipelineId = useStore(s => s.pipelineId)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
|
||||
const [showPublishAsKnowledgePipelineModal, setShowPublishAsKnowledgePipelineModal] = useState(false)
|
||||
const [isPublishingAsCustomizedPipeline, setIsPublishingAsCustomizedPipeline] = useState(false)
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen && confirmVisible)
|
||||
@ -28,38 +41,86 @@ const Publisher = () => {
|
||||
const closePopover = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
const openPublishAsKnowledgePipelineModal = useCallback(() => {
|
||||
setShowPublishAsKnowledgePipelineModal(true)
|
||||
}, [])
|
||||
const hidePublishAsKnowledgePipelineModal = useCallback(() => {
|
||||
setShowPublishAsKnowledgePipelineModal(false)
|
||||
}, [])
|
||||
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
|
||||
try {
|
||||
setIsPublishingAsCustomizedPipeline(true)
|
||||
await publishAsCustomizedPipeline({
|
||||
pipelineId: pipelineId || '',
|
||||
name,
|
||||
icon_info: icon,
|
||||
description,
|
||||
})
|
||||
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
|
||||
description: (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="system-xs-regular text-text-secondary">
|
||||
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
|
||||
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
invalidCustomizedTemplateList()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
setIsPublishingAsCustomizedPipeline(false)
|
||||
hidePublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [docLink, hidePublishAsKnowledgePipelineModal, invalidCustomizedTemplateList, pipelineId, publishAsCustomizedPipeline, t])
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PopoverTrigger
|
||||
nativeButton
|
||||
render={(
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
<PopoverTrigger
|
||||
nativeButton
|
||||
render={(
|
||||
<Button
|
||||
className="px-2"
|
||||
variant="primary"
|
||||
>
|
||||
<span className="pl-1">{t('common.publish', { ns: 'workflow' })}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<PopoverContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
alignOffset={40}
|
||||
popupClassName={cn('border-none bg-transparent shadow-none', confirmVisible && 'hidden')}
|
||||
>
|
||||
<Popup
|
||||
onRequestClose={closePopover}
|
||||
confirmVisible={confirmVisible}
|
||||
onShowConfirm={showConfirm}
|
||||
onHideConfirm={hideConfirm}
|
||||
isPublishingAsCustomizedPipeline={isPublishingAsCustomizedPipeline}
|
||||
onShowPublishAsKnowledgePipelineModal={openPublishAsKnowledgePipelineModal}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{showPublishAsKnowledgePipelineModal && (
|
||||
<PublishAsKnowledgePipelineModal
|
||||
confirmDisabled={isPublishingAsCustomizedPipeline}
|
||||
onConfirm={handlePublishAsKnowledgePipeline}
|
||||
onCancel={hidePublishAsKnowledgePipelineModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -25,7 +24,6 @@ import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
@ -34,9 +32,8 @@ import Link from '@/next/link'
|
||||
import { useParams, useRouter } from '@/next/navigation'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { publishedPipelineInfoQueryKeyPrefix, useInvalidCustomizedTemplateList, usePublishAsCustomizedPipeline } from '@/service/use-pipeline'
|
||||
import { publishedPipelineInfoQueryKeyPrefix } from '@/service/use-pipeline'
|
||||
import { usePublishWorkflow } from '@/service/use-workflow'
|
||||
import PublishAsKnowledgePipelineModal from '../../publish-as-knowledge-pipeline-modal'
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
type PopupProps = {
|
||||
@ -44,6 +41,8 @@ type PopupProps = {
|
||||
confirmVisible?: boolean
|
||||
onShowConfirm?: () => void
|
||||
onHideConfirm?: () => void
|
||||
isPublishingAsCustomizedPipeline?: boolean
|
||||
onShowPublishAsKnowledgePipelineModal?: () => void
|
||||
}
|
||||
|
||||
const Popup = ({
|
||||
@ -51,11 +50,12 @@ const Popup = ({
|
||||
confirmVisible: controlledConfirmVisible,
|
||||
onShowConfirm,
|
||||
onHideConfirm,
|
||||
isPublishingAsCustomizedPipeline = false,
|
||||
onShowPublishAsKnowledgePipelineModal,
|
||||
}: PopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { datasetId } = useParams()
|
||||
const { push } = useRouter()
|
||||
const docLink = useDocLink()
|
||||
const publishedAt = useStore(s => s.publishedAt)
|
||||
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
|
||||
const pipelineId = useStore(s => s.pipelineId)
|
||||
@ -73,9 +73,6 @@ const Popup = ({
|
||||
const showConfirm = onShowConfirm ?? showLocalConfirm
|
||||
const hideConfirm = onHideConfirm ?? hideLocalConfirm
|
||||
const [publishing, { setFalse: hidePublishing, setTrue: showPublishing }] = useBoolean(false)
|
||||
const { mutateAsync: publishAsCustomizedPipeline } = usePublishAsCustomizedPipeline()
|
||||
const [showPublishAsKnowledgePipelineModal, { setFalse: hidePublishAsKnowledgePipelineModal, setTrue: setShowPublishAsKnowledgePipelineModal }] = useBoolean(false)
|
||||
const [isPublishingAsCustomizedPipeline, { setFalse: hidePublishingAsCustomizedPipeline, setTrue: showPublishingAsCustomizedPipeline }] = useBoolean(false)
|
||||
const invalidPublishedPipelineInfo = useInvalid([...publishedPipelineInfoQueryKeyPrefix, pipelineId])
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const handleHideConfirm = useCallback(() => {
|
||||
@ -145,47 +142,15 @@ const Popup = ({
|
||||
const goToAddDocuments = useCallback(() => {
|
||||
push(`/datasets/${datasetId}/documents/create-from-pipeline`)
|
||||
}, [datasetId, push])
|
||||
const invalidCustomizedTemplateList = useInvalidCustomizedTemplateList()
|
||||
const handlePublishAsKnowledgePipeline = useCallback(async (name: string, icon: IconInfo, description?: string) => {
|
||||
try {
|
||||
showPublishingAsCustomizedPipeline()
|
||||
await publishAsCustomizedPipeline({
|
||||
pipelineId: pipelineId || '',
|
||||
name,
|
||||
icon_info: icon,
|
||||
description,
|
||||
})
|
||||
toast.success(t('publishTemplate.success.message', { ns: 'datasetPipeline' }), {
|
||||
description: (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="system-xs-regular text-text-secondary">
|
||||
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
|
||||
</span>
|
||||
<Link href={docLink()} target="_blank" className="inline-block system-xs-medium-uppercase text-text-accent">
|
||||
{t('publishTemplate.success.learnMore', { ns: 'datasetPipeline' })}
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
invalidCustomizedTemplateList()
|
||||
}
|
||||
catch {
|
||||
toast.error(t('publishTemplate.error.message', { ns: 'datasetPipeline' }))
|
||||
}
|
||||
finally {
|
||||
hidePublishingAsCustomizedPipeline()
|
||||
hidePublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal, docLink])
|
||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||
onRequestClose?.()
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
|
||||
setShowPricingModal()
|
||||
}
|
||||
else {
|
||||
setShowPublishAsKnowledgePipelineModal()
|
||||
onShowPublishAsKnowledgePipelineModal?.()
|
||||
}
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, setShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
}, [isAllowPublishAsCustomKnowledgePipelineTemplate, onRequestClose, onShowPublishAsKnowledgePipelineModal, setShowPricingModal])
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5', isAllowPublishAsCustomKnowledgePipelineTemplate ? 'w-[360px]' : 'w-[400px]')}>
|
||||
<div className="p-4 pt-3">
|
||||
@ -279,7 +244,6 @@ const Popup = ({
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{showPublishAsKnowledgePipelineModal && (<PublishAsKnowledgePipelineModal confirmDisabled={isPublishingAsCustomizedPipeline} onConfirm={handlePublishAsKnowledgePipeline} onCancel={hidePublishAsKnowledgePipelineModal} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
94
web/service/knowledge/use-dataset.spec.ts
Normal file
94
web/service/knowledge/use-dataset.spec.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from '../base'
|
||||
import { useDatasetDetail, useDatasetRelatedApps } from './use-dataset'
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: Symbol('keepPreviousData'),
|
||||
useInfiniteQuery: vi.fn(),
|
||||
useMutation: vi.fn(),
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../use-base', () => ({
|
||||
useInvalid: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockUseQuery = vi.mocked(useQuery)
|
||||
const mockGet = vi.mocked(get)
|
||||
|
||||
type QueryOptions = Parameters<typeof useQuery>[0]
|
||||
type RetryFn = (failureCount: number, error: unknown) => boolean
|
||||
|
||||
const getLastQueryOptions = () => {
|
||||
return mockUseQuery.mock.calls.at(-1)?.[0] as QueryOptions
|
||||
}
|
||||
|
||||
const getRetryFn = () => {
|
||||
return getLastQueryOptions().retry as RetryFn
|
||||
}
|
||||
|
||||
describe('knowledge dataset hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseQuery.mockReturnValue({} as ReturnType<typeof useQuery>)
|
||||
})
|
||||
|
||||
describe('useDatasetDetail', () => {
|
||||
it('should not retry forbidden or missing dataset detail errors', () => {
|
||||
// Arrange & Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const retry = getRetryFn()
|
||||
|
||||
// Assert
|
||||
expect(retry(0, new Response(null, { status: 403 }))).toBe(false)
|
||||
expect(retry(0, new Response(null, { status: 404 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('should retry other dataset detail errors fewer than three times', () => {
|
||||
// Arrange & Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const retry = getRetryFn()
|
||||
|
||||
// Assert
|
||||
expect(retry(2, new Error('temporary failure'))).toBe(true)
|
||||
expect(retry(3, new Error('temporary failure'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch dataset detail without silent mode', () => {
|
||||
// Arrange
|
||||
mockGet.mockResolvedValue({ id: 'dataset-1' })
|
||||
|
||||
// Act
|
||||
useDatasetDetail('dataset-1')
|
||||
const queryFn = getLastQueryOptions().queryFn as () => unknown
|
||||
queryFn()
|
||||
|
||||
// Assert
|
||||
expect(mockGet).toHaveBeenCalledWith('/datasets/dataset-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDatasetRelatedApps', () => {
|
||||
it('should use explicit enabled option when provided', () => {
|
||||
// Arrange & Act
|
||||
useDatasetRelatedApps('dataset-1', { enabled: false })
|
||||
|
||||
// Assert
|
||||
expect(getLastQueryOptions().enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable related apps query when dataset id exists and no option is provided', () => {
|
||||
// Arrange & Act
|
||||
useDatasetRelatedApps('dataset-1')
|
||||
|
||||
// Assert
|
||||
expect(getLastQueryOptions().enabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -110,13 +110,20 @@ export const useDatasetDetail = (datasetId: string) => {
|
||||
queryKey: [...datasetDetailQueryKeyPrefix, datasetId],
|
||||
queryFn: () => get<DataSet>(`/datasets/${datasetId}`),
|
||||
enabled: !!datasetId,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof Response && [403, 404].includes(error.status))
|
||||
return false
|
||||
|
||||
return failureCount < 3
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useDatasetRelatedApps = (datasetId: string) => {
|
||||
export const useDatasetRelatedApps = (datasetId: string, options?: { enabled?: boolean }) => {
|
||||
return useQuery({
|
||||
queryKey: [NAME_SPACE, 'related-apps', datasetId],
|
||||
queryFn: () => get<RelatedAppResponse>(`/datasets/${datasetId}/related-apps`),
|
||||
enabled: options?.enabled ?? !!datasetId,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user