mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 13:01:16 +08:00
refactor(web): reuse infinite scroll hook in deployments (#37825)
This commit is contained in:
parent
5b453069d1
commit
50b3228bc7
@ -380,12 +380,8 @@ export const listRollbackTargets = oc
|
||||
)
|
||||
.output(zDeploymentServiceListRollbackTargetsResponse)
|
||||
|
||||
/**
|
||||
* CancelDeployment cancels the in-flight deployment on the environment.
|
||||
*/
|
||||
export const cancelDeployment = oc
|
||||
.route({
|
||||
description: 'CancelDeployment cancels the in-flight deployment on the environment.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'DeploymentService_CancelDeployment',
|
||||
@ -607,13 +603,8 @@ export const releaseService = {
|
||||
precheckRelease,
|
||||
}
|
||||
|
||||
/**
|
||||
* ListEnvironments returns only the environments the current user can
|
||||
* deploy to.
|
||||
*/
|
||||
export const listEnvironments = oc
|
||||
.route({
|
||||
description: 'ListEnvironments returns only the environments the current user can\n deploy to.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'EnvironmentService_ListEnvironments',
|
||||
|
||||
@ -13,13 +13,13 @@ export const AccessMode = {
|
||||
|
||||
export type AccessMode = (typeof AccessMode)[keyof typeof AccessMode]
|
||||
|
||||
export const SubjectType = {
|
||||
SUBJECT_TYPE_UNSPECIFIED: 'SUBJECT_TYPE_UNSPECIFIED',
|
||||
SUBJECT_TYPE_ACCOUNT: 'SUBJECT_TYPE_ACCOUNT',
|
||||
SUBJECT_TYPE_GROUP: 'SUBJECT_TYPE_GROUP',
|
||||
export const AccessSubjectType = {
|
||||
ACCESS_SUBJECT_TYPE_UNSPECIFIED: 'ACCESS_SUBJECT_TYPE_UNSPECIFIED',
|
||||
ACCESS_SUBJECT_TYPE_ACCOUNT: 'ACCESS_SUBJECT_TYPE_ACCOUNT',
|
||||
ACCESS_SUBJECT_TYPE_GROUP: 'ACCESS_SUBJECT_TYPE_GROUP',
|
||||
} as const
|
||||
|
||||
export type SubjectType = (typeof SubjectType)[keyof typeof SubjectType]
|
||||
export type AccessSubjectType = (typeof AccessSubjectType)[keyof typeof AccessSubjectType]
|
||||
|
||||
export const AppRunnerLogStatus = {
|
||||
APP_RUNNER_LOG_STATUS_UNSPECIFIED: 'APP_RUNNER_LOG_STATUS_UNSPECIFIED',
|
||||
@ -295,7 +295,7 @@ export type AccessPolicy = {
|
||||
}
|
||||
|
||||
export type AccessSubject = {
|
||||
subjectType: SubjectType
|
||||
subjectType: AccessSubjectType
|
||||
subjectId: string
|
||||
}
|
||||
|
||||
@ -598,7 +598,6 @@ export type Environment = {
|
||||
status: EnvironmentStatus
|
||||
statusMessage: string
|
||||
lastError?: Error
|
||||
apiServer?: string
|
||||
namespace?: string
|
||||
managedBy?: string
|
||||
runtimeEndpoint?: string
|
||||
@ -741,9 +740,6 @@ export type GetReleaseResponse = {
|
||||
|
||||
export type K8sEnvironmentConfig = {
|
||||
namespace?: string
|
||||
apiServer?: string
|
||||
caBundle?: string
|
||||
bearerToken?: string
|
||||
}
|
||||
|
||||
export type ListApiKeysResponse = {
|
||||
@ -832,6 +828,7 @@ export type PrecheckReleaseResponse = {
|
||||
canCreate: boolean
|
||||
matchedRelease?: ReleaseContentMatch
|
||||
unsupportedNodes: Array<UnsupportedDslNode>
|
||||
unsupportedToolProviders: Array<UnsupportedToolProvider>
|
||||
}
|
||||
|
||||
export type PromoteRequest = {
|
||||
@ -998,6 +995,14 @@ export type UnsupportedDslNode = {
|
||||
type: string
|
||||
}
|
||||
|
||||
export type UnsupportedToolProvider = {
|
||||
nodeId: string
|
||||
providerType: string
|
||||
providerId?: string
|
||||
providerName?: string
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
export type UpdateAccessChannelsRequest = {
|
||||
appInstanceId?: string
|
||||
webAppEnabled?: boolean
|
||||
@ -1362,7 +1367,6 @@ export type InfoConfigReply = {
|
||||
Branding?: BrandingInfo
|
||||
WebAppAuth?: WebAppAuthInfo
|
||||
PluginInstallationPermission?: PluginInstallationPermissionInfo
|
||||
EnableAppDeploy?: boolean
|
||||
}
|
||||
|
||||
export type InnerAdmission = {
|
||||
@ -1458,6 +1462,19 @@ export type IsUserAllowedToAccessWebAppRes = {
|
||||
result?: boolean
|
||||
}
|
||||
|
||||
export type IssueMcpTokenReply = {
|
||||
token?: string
|
||||
expiresAt?: string
|
||||
tokenType?: string
|
||||
}
|
||||
|
||||
export type IssueMcpTokenReq = {
|
||||
userId?: string
|
||||
tenantId?: string
|
||||
appId?: string
|
||||
audience?: string
|
||||
}
|
||||
|
||||
export type JoinWorkspaceReply = {
|
||||
message?: string
|
||||
}
|
||||
@ -1466,6 +1483,7 @@ export type JoinWorkspaceReq = {
|
||||
id?: string
|
||||
email?: string
|
||||
role?: string
|
||||
rbacRole?: string
|
||||
}
|
||||
|
||||
export type LicenseInfo = {
|
||||
@ -1667,12 +1685,9 @@ export type PluginInstallationSettingsReply = {
|
||||
|
||||
export type RbacRole = {
|
||||
id?: string
|
||||
type?: string
|
||||
name?: string
|
||||
description?: string
|
||||
isBuiltin?: boolean
|
||||
category?: string
|
||||
permissionKeys?: Array<string>
|
||||
permissions?: Array<string>
|
||||
}
|
||||
|
||||
export type ResetMemberPasswordReply = {
|
||||
@ -1813,7 +1828,7 @@ export type SetDefaultWorkspaceReq = {
|
||||
|
||||
export type Subject = {
|
||||
subjectId?: string
|
||||
subjectType?: SubjectType
|
||||
subjectType?: string
|
||||
accountData?: SubjectAccountData
|
||||
groupData?: SubjectGroupData
|
||||
}
|
||||
|
||||
@ -9,10 +9,10 @@ export const zAccessMode = z.enum([
|
||||
'ACCESS_MODE_PRIVATE_ALL',
|
||||
])
|
||||
|
||||
export const zSubjectType = z.enum([
|
||||
'SUBJECT_TYPE_UNSPECIFIED',
|
||||
'SUBJECT_TYPE_ACCOUNT',
|
||||
'SUBJECT_TYPE_GROUP',
|
||||
export const zAccessSubjectType = z.enum([
|
||||
'ACCESS_SUBJECT_TYPE_UNSPECIFIED',
|
||||
'ACCESS_SUBJECT_TYPE_ACCOUNT',
|
||||
'ACCESS_SUBJECT_TYPE_GROUP',
|
||||
])
|
||||
|
||||
export const zAppRunnerLogStatus = z.enum([
|
||||
@ -203,7 +203,7 @@ export const zLimitStatus = z.enum([
|
||||
])
|
||||
|
||||
export const zAccessSubject = z.object({
|
||||
subjectType: zSubjectType,
|
||||
subjectType: zAccessSubjectType,
|
||||
subjectId: z.string(),
|
||||
})
|
||||
|
||||
@ -254,10 +254,6 @@ export const zAppInstance = z.object({
|
||||
updatedAt: z.iso.datetime(),
|
||||
})
|
||||
|
||||
/**
|
||||
* BootstrapAssignment is one runtime_instance assignment in a runner's startup
|
||||
* baseline.
|
||||
*/
|
||||
export const zBootstrapAssignment = z.object({
|
||||
appId: z.string().optional(),
|
||||
environmentId: z.string().optional(),
|
||||
@ -322,10 +318,6 @@ export const zCreateReleaseRequest = z.object({
|
||||
sourceAppId: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CredentialCandidate is one tenant-visible credential a frontend may
|
||||
* pick for a credential slot. It carries no secret.
|
||||
*/
|
||||
export const zCredentialCandidate = z.object({
|
||||
credentialId: z.string(),
|
||||
providerId: z.string(),
|
||||
@ -334,20 +326,12 @@ export const zCredentialCandidate = z.object({
|
||||
fromEnterprise: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CredentialSelectionInput is one deploy-time plugin-credential
|
||||
* selection: a shared credential id chosen for a required DSL slot.
|
||||
*/
|
||||
export const zCredentialSelectionInput = z.object({
|
||||
providerId: z.string(),
|
||||
category: zPluginCategory.optional(),
|
||||
credentialId: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CredentialSlot is one model/tool plugin-credential requirement a
|
||||
* Release's DSL declares, paired with the candidates selectable for it.
|
||||
*/
|
||||
export const zCredentialSlot = z.object({
|
||||
providerId: z.string(),
|
||||
category: zPluginCategory,
|
||||
@ -406,10 +390,6 @@ export const zEnvironmentDeploymentRecord = z.object({
|
||||
finalizedAt: z.iso.datetime().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Error is the package-wide failure shape, carried wherever an operation or
|
||||
* resource reports an error.
|
||||
*/
|
||||
export const zError = z.object({
|
||||
code: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
@ -445,7 +425,6 @@ export const zEnvironment = z.object({
|
||||
status: zEnvironmentStatus,
|
||||
statusMessage: z.string(),
|
||||
lastError: zError.optional(),
|
||||
apiServer: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
managedBy: z.string().optional(),
|
||||
runtimeEndpoint: z.string().optional(),
|
||||
@ -523,9 +502,6 @@ export const zGetEnvironmentResponse = z.object({
|
||||
|
||||
export const zK8sEnvironmentConfig = z.object({
|
||||
namespace: z.string().optional(),
|
||||
apiServer: z.string().optional(),
|
||||
caBundle: z.string().optional(),
|
||||
bearerToken: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zCreateEnvironmentRequest = z.object({
|
||||
@ -571,9 +547,6 @@ export const zDeployRequest = z.object({
|
||||
expectedDslDigest: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Operator is who triggered the run (the "END USER OR ACCOUNT" column).
|
||||
*/
|
||||
export const zOperator = z.object({
|
||||
type: zOperatorType,
|
||||
id: z.string(),
|
||||
@ -620,10 +593,6 @@ export const zPromoteRequest = z.object({
|
||||
idempotencyKey: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ReleaseContentMatch identifies an existing release whose DSL content is
|
||||
* identical to the checked content.
|
||||
*/
|
||||
export const zReleaseContentMatch = z.object({
|
||||
releaseId: z.string(),
|
||||
displayName: z.string(),
|
||||
@ -638,11 +607,6 @@ export const zReleaseEnvironmentAction = z.object({
|
||||
currentReleaseId: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ReleaseEnvironmentDeployment is an environment where the release is the
|
||||
* active deployment, paired with that environment's runtime status so the
|
||||
* version history can show running vs failed vs deploying.
|
||||
*/
|
||||
export const zReleaseEnvironmentDeployment = z.object({
|
||||
environment: zEnvironment,
|
||||
status: zRuntimeInstanceStatus,
|
||||
@ -663,10 +627,6 @@ export const zReportRuntimeAssignmentStatusResponse = z.object({
|
||||
stale: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RequiredSlot is an input requirement extracted from a Release's
|
||||
* DSL.
|
||||
*/
|
||||
export const zRequiredSlot = z.object({
|
||||
type: zSlotType,
|
||||
providerId: z.string(),
|
||||
@ -715,10 +675,6 @@ export const zDeployResponse = z.object({
|
||||
deployment: zDeployment,
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvironmentAppInstance is one app instance as seen from a single environment:
|
||||
* its current release, runtime status, and derived last error in THIS env.
|
||||
*/
|
||||
export const zEnvironmentAppInstance = z.object({
|
||||
appInstance: zAppInstance.optional(),
|
||||
currentRelease: zRelease.optional(),
|
||||
@ -759,10 +715,6 @@ export const zComputeReleaseDeploymentViewResponse = z.object({
|
||||
options: zDeploymentOptions.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvironmentDeploymentHistoryItem is one deployment row in an environment's
|
||||
* history, with a thin reference to the owning app instance.
|
||||
*/
|
||||
export const zEnvironmentDeploymentHistoryItem = z.object({
|
||||
deployment: zDeployment.optional(),
|
||||
appInstanceId: z.string().optional(),
|
||||
@ -904,20 +856,25 @@ export const zUndeployResponse = z.object({
|
||||
deployment: zDeployment,
|
||||
})
|
||||
|
||||
/**
|
||||
* UnsupportedDslNode identifies a workflow node whose type the app runner
|
||||
* cannot execute.
|
||||
*/
|
||||
export const zUnsupportedDslNode = z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
export const zUnsupportedToolProvider = z.object({
|
||||
nodeId: z.string(),
|
||||
providerType: z.string(),
|
||||
providerId: z.string().optional(),
|
||||
providerName: z.string().optional(),
|
||||
toolName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zPrecheckReleaseResponse = z.object({
|
||||
gateCommitId: z.string(),
|
||||
canCreate: z.boolean(),
|
||||
matchedRelease: zReleaseContentMatch.optional(),
|
||||
unsupportedNodes: z.array(zUnsupportedDslNode),
|
||||
unsupportedToolProviders: z.array(zUnsupportedToolProvider),
|
||||
})
|
||||
|
||||
export const zUpdateAccessChannelsRequest = z.object({
|
||||
@ -1302,6 +1259,19 @@ export const zIsUserAllowedToAccessWebAppRes = z.object({
|
||||
result: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zIssueMcpTokenReply = z.object({
|
||||
token: z.string().optional(),
|
||||
expiresAt: z.string().optional(),
|
||||
tokenType: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zIssueMcpTokenReq = z.object({
|
||||
userId: z.string().optional(),
|
||||
tenantId: z.string().optional(),
|
||||
appId: z.string().optional(),
|
||||
audience: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zJoinWorkspaceReply = z.object({
|
||||
message: z.string().optional(),
|
||||
})
|
||||
@ -1313,6 +1283,7 @@ export const zJoinWorkspaceReq = z.object({
|
||||
id: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
rbacRole: z.string().optional(),
|
||||
})
|
||||
|
||||
export const zLimitConfig = z.object({
|
||||
@ -1494,12 +1465,9 @@ export const zPluginInstallationSettingsReply = z.object({
|
||||
|
||||
export const zRbacRole = z.object({
|
||||
id: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
isBuiltin: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
permissionKeys: z.array(z.string()).optional(),
|
||||
permissions: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const zGetMemberRbacRolesReply = z.object({
|
||||
@ -1778,7 +1746,7 @@ export const zGetWebAppWhitelistSubjectsRes = z.object({
|
||||
*/
|
||||
export const zSubject = z.object({
|
||||
subjectId: z.string().optional(),
|
||||
subjectType: zSubjectType.optional(),
|
||||
subjectType: z.string().optional(),
|
||||
accountData: zSubjectAccountData.optional(),
|
||||
groupData: zSubjectGroupData.optional(),
|
||||
})
|
||||
@ -2104,7 +2072,6 @@ export const zInfoConfigReply = z.object({
|
||||
Branding: zBrandingInfo.optional(),
|
||||
WebAppAuth: zWebAppAuthInfo.optional(),
|
||||
PluginInstallationPermission: zPluginInstallationPermissionInfo.optional(),
|
||||
EnableAppDeploy: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const zWebOAuth2LoginReply = z.object({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
@ -375,8 +375,8 @@ describe('AccessControl', () => {
|
||||
appId: app.id,
|
||||
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
subjects: [
|
||||
{ subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT },
|
||||
{ subjectId: baseGroup.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: baseMember.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { Subject as EnterpriseSubject } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { App } from '@/types/app'
|
||||
import { SubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessSubjectType as EnterpriseSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -94,12 +94,12 @@ function AccessControlForm({
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<EnterpriseSubject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.SUBJECT_TYPE_GROUP })
|
||||
subjects.push({ subjectId: group.id, subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: EnterpriseSubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: EnterpriseSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
|
||||
@ -1,7 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SourceStepContent } from '../source-step'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const sourceAppsQuery = {
|
||||
data: { pages: [{ data: [] }] },
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAppsQuery,
|
||||
useInfiniteScroll: vi.fn(() => ({
|
||||
rootEl: null,
|
||||
rootRef: vi.fn(),
|
||||
sentinelEl: null,
|
||||
sentinelRef: vi.fn(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
|
||||
useInfiniteScroll: mocks.useInfiniteScroll,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/deployments/create-guide/state', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
const methodAtom = atom<'bindApp' | 'importDsl'>('bindApp')
|
||||
@ -22,15 +48,7 @@ vi.mock('@/features/deployments/create-guide/state', async () => {
|
||||
}),
|
||||
selectSourceAppAtom: emptyActionAtom,
|
||||
setSourceSearchTextAtom: emptyActionAtom,
|
||||
sourceAppsQueryAtom: atom({
|
||||
data: { pages: [{ data: [] }] },
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
}),
|
||||
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
|
||||
sourceCanGoNextAtom: atom(false),
|
||||
sourceSearchTextAtom: atom(''),
|
||||
unsupportedDslNodesAtom: atom([]),
|
||||
@ -38,6 +56,19 @@ vi.mock('@/features/deployments/create-guide/state', async () => {
|
||||
})
|
||||
|
||||
describe('SourceStepContent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(mocks.sourceAppsQuery, {
|
||||
data: { pages: [{ data: [] }] },
|
||||
hasNextPage: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
isPlaceholderData: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide the import DSL option when deployment DSL import is disabled', () => {
|
||||
render(<SourceStepContent />)
|
||||
|
||||
@ -46,4 +77,29 @@ describe('SourceStepContent', () => {
|
||||
expect(screen.queryByText(/createGuide\.methods\.importDsl\.description/)).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: /createGuide\.source\.sourceApp/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use infinite scroll to load more source apps', () => {
|
||||
Object.assign(mocks.sourceAppsQuery, {
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
name: 'Workflow App',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
hasNextPage: true,
|
||||
})
|
||||
|
||||
render(<SourceStepContent />)
|
||||
|
||||
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
|
||||
mocks.sourceAppsQuery,
|
||||
expect.objectContaining({
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
}),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
unsupportedDslNodesAtom,
|
||||
} from '@/features/deployments/create-guide/state'
|
||||
import { isDeploymentDslImportEnabled } from '@/features/deployments/shared/domain/feature-flags'
|
||||
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
|
||||
import { StepShell } from './layout'
|
||||
|
||||
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
|
||||
@ -187,9 +188,14 @@ function SourceAppList() {
|
||||
const sourceAppsQuery = useAtomValue(sourceAppsQueryAtom)
|
||||
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
|
||||
const sourceAppsLoading = sourceAppsQuery.isLoading || sourceAppsQuery.isPlaceholderData || (sourceAppsQuery.isFetching && sourceApps.length === 0)
|
||||
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(sourceAppsQuery, {
|
||||
enabled: !sourceAppsLoading,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
||||
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
||||
{sourceAppsLoading
|
||||
? <SourceAppSkeleton />
|
||||
: sourceApps.length === 0
|
||||
@ -208,20 +214,12 @@ function SourceAppList() {
|
||||
onSelect={() => selectSourceApp(app)}
|
||||
/>
|
||||
))}
|
||||
{sourceAppsQuery.hasNextPage && (
|
||||
<div className="flex justify-center border-t border-divider-subtle px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
disabled={sourceAppsQuery.isFetchingNextPage}
|
||||
onClick={() => {
|
||||
void sourceAppsQuery.fetchNextPage()
|
||||
}}
|
||||
>
|
||||
{sourceAppsQuery.isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
|
||||
</Button>
|
||||
{sourceAppsQuery.isFetchingNextPage && (
|
||||
<div className="border-t border-divider-subtle px-3 py-2 text-center system-xs-regular text-text-tertiary">
|
||||
{t('createModal.loadingApps')}
|
||||
</div>
|
||||
)}
|
||||
{sourceAppsQuery.hasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,8 +1,51 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SourceAppPicker } from '../source-app-picker'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const sourceAppsQuery = {
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
name: 'Workflow App',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
return {
|
||||
sourceAppsQuery,
|
||||
useInfiniteScroll: vi.fn(() => ({
|
||||
rootEl: null,
|
||||
rootRef: vi.fn(),
|
||||
sentinelEl: null,
|
||||
sentinelRef: vi.fn(),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/create-release/state', async () => {
|
||||
const { atom } = await import('jotai')
|
||||
|
||||
return {
|
||||
createReleaseSourceAppSearchTextAtom: atom(''),
|
||||
createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/deployments/shared/hooks/use-infinite-scroll', () => ({
|
||||
useInfiniteScroll: mocks.useInfiniteScroll,
|
||||
}))
|
||||
|
||||
function renderSourceAppPicker(disabled: boolean) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -24,10 +67,59 @@ function renderSourceAppPicker(disabled: boolean) {
|
||||
}
|
||||
|
||||
describe('SourceAppPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.assign(mocks.sourceAppsQuery, {
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'app-1',
|
||||
name: 'Workflow App',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable the switch control when disabled', () => {
|
||||
renderSourceAppPicker(true)
|
||||
|
||||
expect(screen.getByText('Workflow 1')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should use infinite scroll to load more apps when the picker is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderSourceAppPicker(false)
|
||||
|
||||
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
|
||||
mocks.sourceAppsQuery,
|
||||
expect.objectContaining({
|
||||
enabled: false,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
}),
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: 'deployments.versions.sourceAppOption' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith(
|
||||
mocks.sourceAppsQuery,
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: /createModal\.loadMoreApps/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
import type { SourceAppPickerValue } from '../state'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
@ -19,6 +18,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
import {
|
||||
createReleaseSourceAppSearchTextAtom,
|
||||
@ -134,13 +134,18 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const searchText = useAtomValue(createReleaseSourceAppSearchTextAtom)
|
||||
const setSearchText = useSetAtom(createReleaseSourceAppSearchTextAtom)
|
||||
const sourceAppsQuery = useAtomValue(createReleaseSourceAppsQueryAtom)
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useAtomValue(createReleaseSourceAppsQueryAtom)
|
||||
} = sourceAppsQuery
|
||||
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(sourceAppsQuery, {
|
||||
enabled: isShow && !disabled,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
const apps = data?.pages.flatMap(page => page.data) ?? []
|
||||
|
||||
@ -202,7 +207,7 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
|
||||
<ComboboxList className="max-h-none p-0">
|
||||
{(app: App) => (
|
||||
@ -214,20 +219,12 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
|
||||
{t('createModal.appSearchEmpty')}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => {
|
||||
void fetchNextPage()
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage ? t('createModal.loadingApps') : t('createModal.loadMoreApps')}
|
||||
</Button>
|
||||
{isFetchingNextPage && apps.length > 0 && (
|
||||
<div className="px-3 py-2 text-center system-xs-regular text-text-tertiary">
|
||||
{t('createModal.loadingApps')}
|
||||
</div>
|
||||
)}
|
||||
{hasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
|
||||
@ -2,7 +2,7 @@ import type {
|
||||
AccessPolicy,
|
||||
Subject,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode, SubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
import {
|
||||
@ -56,7 +56,7 @@ describe('access policy mode mapping', () => {
|
||||
describe('access policy subject conversion', () => {
|
||||
it('should normalize resolved group and account subjects', () => {
|
||||
expect(normalizeResolvedSubject({
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
groupData: {
|
||||
id: 'group-1',
|
||||
name: 'Admins',
|
||||
@ -64,53 +64,53 @@ describe('access policy subject conversion', () => {
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'group-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: 'Admins',
|
||||
memberCount: 3,
|
||||
})
|
||||
|
||||
expect(normalizeResolvedSubject({
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
accountData: {
|
||||
id: 'account-1',
|
||||
email: 'member@example.com',
|
||||
},
|
||||
})).toEqual({
|
||||
id: 'account-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
name: 'member@example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore unsupported subjects and subjects without ids', () => {
|
||||
expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_GROUP })).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT })).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: SubjectType.SUBJECT_TYPE_UNSPECIFIED } as Subject)).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP })).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT })).toBeUndefined()
|
||||
expect(normalizeResolvedSubject({ subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_UNSPECIFIED } as Subject)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should preserve labels when reading selected subjects from policy', () => {
|
||||
expect(selectedSubjectsFromPolicy(policy({
|
||||
subjects: [
|
||||
{ subjectId: 'group-1', subjectType: SubjectType.SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: 'account-1', subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT },
|
||||
{ subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
|
||||
],
|
||||
}), [
|
||||
{
|
||||
id: 'group-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: 'Admins',
|
||||
memberCount: 3,
|
||||
},
|
||||
])).toEqual([
|
||||
{
|
||||
id: 'group-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: 'Admins',
|
||||
memberCount: 3,
|
||||
},
|
||||
{
|
||||
id: 'account-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
},
|
||||
])
|
||||
|
||||
@ -121,20 +121,20 @@ describe('access policy subject conversion', () => {
|
||||
const subjects = [
|
||||
{
|
||||
id: 'group-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: 'Admins',
|
||||
memberCount: 3,
|
||||
},
|
||||
{
|
||||
id: 'account-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
name: 'Member',
|
||||
},
|
||||
]
|
||||
|
||||
expect(policySubjects(subjects)).toEqual([
|
||||
{ subjectId: 'group-1', subjectType: SubjectType.SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: 'account-1', subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT },
|
||||
{ subjectId: 'group-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP },
|
||||
{ subjectId: 'account-1', subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT },
|
||||
])
|
||||
|
||||
const selection = accessControlSelectionFromSubjects(subjects)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { AccessPolicy, Environment, EnvironmentAccessPolicy } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AccessMode, SubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode, AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@ -10,10 +10,20 @@ import { AccessPermissionsSection } from '../permissions-section'
|
||||
const mockMutate = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useInfiniteQuery: () => ({
|
||||
data: { pages: [] },
|
||||
fetchNextPage: vi.fn(),
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
}),
|
||||
useMutation: () => ({
|
||||
isPending: false,
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
useQuery: () => ({
|
||||
data: undefined,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
@ -63,11 +73,11 @@ function createSpecificAccessPolicy(): AccessPolicy {
|
||||
subjects: [
|
||||
{
|
||||
subjectId: 'group-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
},
|
||||
{
|
||||
subjectId: 'member-1',
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -106,6 +116,89 @@ describe('EnvironmentPermissionRow', () => {
|
||||
expect(screen.getByText('deployments.access.permission.organization')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the updated policy after success', () => {
|
||||
mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithAtomStore(
|
||||
<EnvironmentPermissionRow
|
||||
appInstanceId="app-instance-1"
|
||||
environment={createEnvironment()}
|
||||
summaryPolicy={createAccessPolicy()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'app.accessControlDialog.accessItems.anyone' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
},
|
||||
body: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
mode: AccessMode.ACCESS_MODE_PUBLIC,
|
||||
subjects: [],
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
expect(screen.getByText('deployments.access.permission.anyone')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit specific subjects with the deployment access subject type', () => {
|
||||
mockMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithAtomStore(
|
||||
<EnvironmentPermissionRow
|
||||
appInstanceId="app-instance-1"
|
||||
environment={createEnvironment()}
|
||||
summaryPolicy={createSpecificAccessPolicy()}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /deployments\.access\.permissions\.editAriaLabel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
},
|
||||
body: {
|
||||
appInstanceId: 'app-instance-1',
|
||||
environmentId: 'environment-1',
|
||||
mode: AccessMode.ACCESS_MODE_PRIVATE,
|
||||
subjects: [
|
||||
{
|
||||
subjectId: 'group-1',
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
},
|
||||
{
|
||||
subjectId: 'member-1',
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
onError: expect.any(Function),
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should show specific subject counts in the access summary', () => {
|
||||
renderWithAtomStore(
|
||||
<EnvironmentPermissionRow
|
||||
|
||||
@ -2,7 +2,7 @@ import type {
|
||||
AccessPolicy,
|
||||
AccessMode as AccessPolicyMode,
|
||||
AccessSubject,
|
||||
SubjectType as AccessSubjectType,
|
||||
AccessSubjectType as AccessSubjectTypeValue,
|
||||
Subject,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
|
||||
@ -12,7 +12,7 @@ import type {
|
||||
} from '@/models/access-control'
|
||||
import {
|
||||
AccessMode,
|
||||
SubjectType,
|
||||
AccessSubjectType,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessMode as AppAccessMode } from '@/models/access-control'
|
||||
|
||||
@ -26,7 +26,7 @@ export const permissionIcon: Record<AccessPermissionKind, string> = {
|
||||
|
||||
export type SelectableAccessSubject = {
|
||||
id: string
|
||||
subjectType: AccessSubjectType
|
||||
subjectType: AccessSubjectTypeValue
|
||||
name?: string
|
||||
memberCount?: number
|
||||
}
|
||||
@ -64,27 +64,27 @@ export function appAccessModeToPermissionKey(mode: AppAccessMode): AccessPermiss
|
||||
}
|
||||
|
||||
export function normalizeResolvedSubject(subject: Subject): SelectableAccessSubject | undefined {
|
||||
if (subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP) {
|
||||
if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP) {
|
||||
const id = subject.subjectId || subject.groupData?.id
|
||||
if (!id)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id,
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: subject.groupData?.name,
|
||||
memberCount: subject.groupData?.groupSize,
|
||||
}
|
||||
}
|
||||
|
||||
if (subject.subjectType === SubjectType.SUBJECT_TYPE_ACCOUNT) {
|
||||
if (subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT) {
|
||||
const id = subject.subjectId || subject.accountData?.id
|
||||
if (!id)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id,
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
name: subject.accountData?.name || subject.accountData?.email,
|
||||
}
|
||||
}
|
||||
@ -144,10 +144,10 @@ function selectableSubjectToAccount(subject: SelectableAccessSubject): AccessCon
|
||||
export function accessControlSelectionFromSubjects(subjects: SelectableAccessSubject[]): AccessSubjectSelectionValue {
|
||||
return {
|
||||
groups: subjects
|
||||
.filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP)
|
||||
.filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP)
|
||||
.map(selectableSubjectToGroup),
|
||||
members: subjects
|
||||
.filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_ACCOUNT)
|
||||
.filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT)
|
||||
.map(selectableSubjectToAccount),
|
||||
}
|
||||
}
|
||||
@ -156,13 +156,13 @@ export function subjectsFromAccessControlSelection(value: AccessSubjectSelection
|
||||
return [
|
||||
...value.groups.map((group): SelectableAccessSubject => ({
|
||||
id: group.id,
|
||||
subjectType: SubjectType.SUBJECT_TYPE_GROUP,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP,
|
||||
name: group.name,
|
||||
memberCount: group.groupSize,
|
||||
})),
|
||||
...value.members.map((member): SelectableAccessSubject => ({
|
||||
id: member.id,
|
||||
subjectType: SubjectType.SUBJECT_TYPE_ACCOUNT,
|
||||
subjectType: AccessSubjectType.ACCESS_SUBJECT_TYPE_ACCOUNT,
|
||||
name: member.name || member.email,
|
||||
})),
|
||||
]
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
} from './access-policy'
|
||||
import type { AccessSubjectSelectionValue } from '@/app/components/app/app-access-control/access-subject-selector/types'
|
||||
import type { AccessControlDraft } from '@/app/components/app/app-access-control/store'
|
||||
import { SubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { AccessSubjectType } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessControlDialog } from '@/app/components/app/app-access-control/access-control-dialog'
|
||||
@ -35,7 +35,7 @@ export function PermissionSummaryButton({
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const groupCount = subjects?.filter(subject => subject.subjectType === SubjectType.SUBJECT_TYPE_GROUP).length ?? 0
|
||||
const groupCount = subjects?.filter(subject => subject.subjectType === AccessSubjectType.ACCESS_SUBJECT_TYPE_GROUP).length ?? 0
|
||||
const memberCount = (subjects?.length ?? 0) - groupCount
|
||||
const countLabels = [
|
||||
...(groupCount > 0 ? [t('access.members.groupCount', { count: groupCount })] : []),
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { ListReleaseSummariesResponse } from '@dify/contracts/enterprise/types.gen'
|
||||
import { keepPreviousData, skipToken } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
import { atomWithQuery } from 'jotai-tanstack-query'
|
||||
@ -11,7 +10,7 @@ import { RELEASE_HISTORY_PAGE_SIZE } from '../../shared/domain/pagination'
|
||||
export const releaseHistoryCurrentPageAtom = atom(0)
|
||||
export const deployReleaseMenuOpenReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
export const releaseHistoryQueryAtom = atomWithQuery<ListReleaseSummariesResponse>((get) => {
|
||||
export const releaseHistoryQueryAtom = atomWithQuery((get) => {
|
||||
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
|
||||
const currentPage = get(releaseHistoryCurrentPageAtom)
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { ListAppInstanceSummariesResponse } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
@ -34,21 +32,7 @@ export function DeploymentsListStateBoundary({ children }: {
|
||||
return children
|
||||
}
|
||||
|
||||
function listDeploymentStatusPollingInterval(data?: InfiniteData<ListAppInstanceSummariesResponse>) {
|
||||
const rows = data?.pages?.flatMap(page =>
|
||||
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
|
||||
) ?? []
|
||||
|
||||
return deploymentStatusPollingInterval(rows)
|
||||
}
|
||||
|
||||
export const deploymentsListQueryAtom = atomWithInfiniteQuery<
|
||||
ListAppInstanceSummariesResponse,
|
||||
Error,
|
||||
InfiniteData<ListAppInstanceSummariesResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>((get) => {
|
||||
export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
|
||||
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
|
||||
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
|
||||
|
||||
@ -64,7 +48,13 @@ export const deploymentsListQueryAtom = atomWithInfiniteQuery<
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: query => listDeploymentStatusPollingInterval(query.state.data),
|
||||
refetchInterval: (query) => {
|
||||
const rows = query.state.data?.pages.flatMap(page =>
|
||||
page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments),
|
||||
) ?? []
|
||||
|
||||
return deploymentStatusPollingInterval(rows)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StudioListHeader } from '@/app/components/apps/studio-list-header'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
|
||||
import { useInfiniteScroll } from '../../shared/hooks/use-infinite-scroll'
|
||||
import {
|
||||
deploymentsListHasFilterAtom,
|
||||
deploymentsListQueryAtom,
|
||||
@ -157,48 +157,22 @@ function DeploymentsListControls() {
|
||||
|
||||
export function DeploymentsListShell() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom)
|
||||
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
|
||||
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
|
||||
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
|
||||
const {
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = deploymentsListQuery
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isLoading || isFetchingNextPage || error)
|
||||
return
|
||||
|
||||
const anchor = anchorRef.current
|
||||
const container = containerRef.current
|
||||
if (!anchor || !container)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting)
|
||||
void fetchNextPage()
|
||||
}, {
|
||||
root: container,
|
||||
rootMargin: '160px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observer.observe(anchor)
|
||||
return () => observer.disconnect()
|
||||
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
|
||||
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(deploymentsListQuery)
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<div ref={rootRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<DeploymentsListControls />
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-8 pt-2',
|
||||
'relative grid grow grid-cols-[repeat(auto-fill,minmax(min(100%,20rem),1fr))] content-start gap-4 px-8 pt-2 pb-8',
|
||||
showEmptyState && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
@ -215,10 +189,8 @@ export function DeploymentsListShell() {
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && <DeploymentsListSkeleton />}
|
||||
<div ref={sentinelRef} aria-hidden="true" className="col-span-full h-px" />
|
||||
</div>
|
||||
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
<div className="py-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,9 +3,7 @@
|
||||
import type {
|
||||
AppInstance,
|
||||
GetAppInstanceResponse,
|
||||
ListAppInstancesResponse,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InfiniteData, QueryKey } from '@tanstack/react-query'
|
||||
import type { NavItem } from '@/app/components/header/nav/nav-selector'
|
||||
import { keepPreviousData, skipToken } from '@tanstack/react-query'
|
||||
import { atom } from 'jotai'
|
||||
@ -64,13 +62,7 @@ const deploymentsNavCurrentInstanceQueryAtom = atomWithQuery((get) => {
|
||||
})
|
||||
})
|
||||
|
||||
export const deploymentsNavListQueryAtom = atomWithInfiniteQuery<
|
||||
ListAppInstancesResponse,
|
||||
Error,
|
||||
InfiniteData<ListAppInstancesResponse>,
|
||||
QueryKey,
|
||||
number
|
||||
>((get) => {
|
||||
export const deploymentsNavListQueryAtom = atomWithInfiniteQuery((get) => {
|
||||
const isActive = get(deploymentsRouteActiveAtom)
|
||||
|
||||
return consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
|
||||
|
||||
@ -0,0 +1,199 @@
|
||||
import type { InfiniteScrollQueryResult, UseInfiniteScrollOptions } from '../use-infinite-scroll'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useInfiniteScroll } from '../use-infinite-scroll'
|
||||
|
||||
let intersectionCallback: IntersectionObserverCallback | undefined
|
||||
let intersectionOptions: IntersectionObserverInit | undefined
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
const unobserve = vi.fn()
|
||||
const originalIntersectionObserver = globalThis.IntersectionObserver
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
readonly root: Element | Document | null
|
||||
readonly rootMargin: string
|
||||
readonly scrollMargin = ''
|
||||
readonly thresholds: ReadonlyArray<number>
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
intersectionCallback = callback
|
||||
intersectionOptions = options
|
||||
this.root = options?.root ?? null
|
||||
this.rootMargin = options?.rootMargin ?? ''
|
||||
this.thresholds = Array.isArray(options?.threshold)
|
||||
? options.threshold
|
||||
: [options?.threshold ?? 0]
|
||||
}
|
||||
|
||||
observe = observe
|
||||
unobserve = unobserve
|
||||
disconnect = disconnect
|
||||
takeRecords = () => []
|
||||
}
|
||||
|
||||
function TestInfiniteScroll({
|
||||
options,
|
||||
query,
|
||||
}: {
|
||||
options?: UseInfiniteScrollOptions
|
||||
query: InfiniteScrollQueryResult
|
||||
}) {
|
||||
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement, HTMLDivElement>(query, options)
|
||||
|
||||
return createElement(
|
||||
'div',
|
||||
{ 'ref': rootRef, 'data-testid': 'scroll-root' },
|
||||
createElement('div', { 'ref': sentinelRef, 'data-testid': 'scroll-sentinel' }),
|
||||
)
|
||||
}
|
||||
|
||||
function createInfiniteScrollQuery(overrides: Partial<InfiniteScrollQueryResult> = {}): InfiniteScrollQueryResult {
|
||||
return {
|
||||
error: null,
|
||||
fetchNextPage: vi.fn(() => Promise.resolve()),
|
||||
hasNextPage: true,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function renderInfiniteScroll(
|
||||
query: InfiniteScrollQueryResult,
|
||||
options?: UseInfiniteScrollOptions,
|
||||
) {
|
||||
const view = render(createElement(TestInfiniteScroll, { options, query }))
|
||||
|
||||
return {
|
||||
...view,
|
||||
root: view.getByTestId('scroll-root'),
|
||||
sentinel: view.getByTestId('scroll-sentinel'),
|
||||
}
|
||||
}
|
||||
|
||||
function triggerIntersection(isIntersecting: boolean) {
|
||||
if (!intersectionCallback)
|
||||
throw new Error('Expected IntersectionObserver callback to be registered')
|
||||
|
||||
intersectionCallback([
|
||||
{ isIntersecting } as IntersectionObserverEntry,
|
||||
], {} as IntersectionObserver)
|
||||
}
|
||||
|
||||
describe('useInfiniteScroll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
intersectionCallback = undefined
|
||||
intersectionOptions = undefined
|
||||
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.IntersectionObserver = originalIntersectionObserver
|
||||
})
|
||||
|
||||
// The hook owns both refs and wires the sentinel to the scroll container root.
|
||||
it('should observe the sentinel with the scroll root', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
const { root, sentinel } = renderInfiniteScroll(query)
|
||||
|
||||
expect(observe).toHaveBeenCalledWith(sentinel)
|
||||
expect(intersectionOptions?.root).toBe(root)
|
||||
expect(intersectionOptions?.rootMargin).toBe('0px 0px 300px 0px')
|
||||
expect(intersectionOptions?.threshold).toBe(0)
|
||||
})
|
||||
|
||||
// Pagination starts when the sentinel enters the configured observer area.
|
||||
it('should load more when the sentinel intersects', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
renderInfiniteScroll(query)
|
||||
|
||||
triggerIntersection(true)
|
||||
|
||||
expect(query.fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: false })
|
||||
})
|
||||
|
||||
// Non-intersecting observer entries should not advance the query.
|
||||
it('should not load more when the sentinel is outside the observer area', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
renderInfiniteScroll(query)
|
||||
|
||||
triggerIntersection(false)
|
||||
|
||||
expect(query.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Query state should gate observer registration so stale or duplicate loads do not fire.
|
||||
it.each([
|
||||
['has no next page', { hasNextPage: false }],
|
||||
['is loading', { isLoading: true }],
|
||||
['is fetching the next page', { isFetchingNextPage: true }],
|
||||
['is fetching and guarded', { isFetching: true }],
|
||||
['has an error', { error: new Error('load failed') }],
|
||||
] as const)('should not observe when the query %s', (_label, overrides) => {
|
||||
const query = createInfiniteScrollQuery(overrides)
|
||||
|
||||
renderInfiniteScroll(query)
|
||||
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
expect(query.fetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Options are passed through to IntersectionObserver and fetchNextPage.
|
||||
it('should use custom observer and fetch options', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
renderInfiniteScroll(query, {
|
||||
cancelRefetch: true,
|
||||
rootMargin: '0px 0px 160px 0px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
triggerIntersection(true)
|
||||
|
||||
expect(intersectionOptions?.rootMargin).toBe('0px 0px 160px 0px')
|
||||
expect(intersectionOptions?.threshold).toBe(0.1)
|
||||
expect(query.fetchNextPage).toHaveBeenCalledWith({ cancelRefetch: true })
|
||||
})
|
||||
|
||||
// Window mode observes against the viewport instead of the local scroll container.
|
||||
it('should use the viewport root when useWindow is enabled', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
renderInfiniteScroll(query, { useWindow: true })
|
||||
|
||||
expect(intersectionOptions?.root).toBeNull()
|
||||
})
|
||||
|
||||
// The local lock avoids repeated calls while TanStack Query is still resolving fetchNextPage.
|
||||
it('should not request another page while the previous request is pending', async () => {
|
||||
let resolveFetch: (value?: unknown) => void = () => undefined
|
||||
const fetchNextPage = vi.fn(() => new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
}))
|
||||
const query = createInfiniteScrollQuery({ fetchNextPage })
|
||||
renderInfiniteScroll(query)
|
||||
|
||||
triggerIntersection(true)
|
||||
triggerIntersection(true)
|
||||
|
||||
expect(fetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch()
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
// Cleanup matters when the list unmounts or query state recreates the observer.
|
||||
it('should disconnect the observer on unmount', () => {
|
||||
const query = createInfiniteScrollQuery()
|
||||
const { unmount } = renderInfiniteScroll(query)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
147
web/features/deployments/shared/hooks/use-infinite-scroll.ts
Normal file
147
web/features/deployments/shared/hooks/use-infinite-scroll.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import type { RefCallback } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type FetchNextPageOptions = {
|
||||
cancelRefetch?: boolean
|
||||
}
|
||||
|
||||
export type InfiniteScrollQueryResult = {
|
||||
error?: unknown
|
||||
fetchNextPage: (options?: FetchNextPageOptions) => Promise<unknown> | unknown
|
||||
hasNextPage?: boolean
|
||||
isFetching?: boolean
|
||||
isFetchingNextPage: boolean
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export type UseInfiniteScrollOptions = {
|
||||
cancelRefetch?: boolean
|
||||
enabled?: boolean
|
||||
guardOnFetching?: boolean
|
||||
rootMargin?: string
|
||||
threshold?: number | number[]
|
||||
useWindow?: boolean
|
||||
}
|
||||
|
||||
type UseInfiniteScrollResult<TRoot extends Element, TTarget extends Element> = {
|
||||
rootEl: TRoot | null
|
||||
rootRef: RefCallback<TRoot>
|
||||
sentinelEl: TTarget | null
|
||||
sentinelRef: RefCallback<TTarget>
|
||||
}
|
||||
|
||||
export function useInfiniteScroll<
|
||||
TRoot extends Element = HTMLDivElement,
|
||||
TTarget extends Element = HTMLDivElement,
|
||||
>(
|
||||
query: InfiniteScrollQueryResult,
|
||||
options: UseInfiniteScrollOptions = {},
|
||||
): UseInfiniteScrollResult<TRoot, TTarget> {
|
||||
const {
|
||||
cancelRefetch = false,
|
||||
enabled = true,
|
||||
guardOnFetching = true,
|
||||
rootMargin = '0px 0px 300px 0px',
|
||||
threshold = 0,
|
||||
useWindow = false,
|
||||
} = options
|
||||
|
||||
const [rootEl, setRootEl] = useState<TRoot | null>(null)
|
||||
const [sentinelEl, setSentinelEl] = useState<TTarget | null>(null)
|
||||
const loadingLockRef = useRef(false)
|
||||
|
||||
const latestRef = useRef({
|
||||
cancelRefetch,
|
||||
enabled,
|
||||
error: query.error,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
guardOnFetching,
|
||||
hasNextPage: Boolean(query.hasNextPage),
|
||||
isFetching: query.isFetching ?? false,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoading: query.isLoading ?? false,
|
||||
})
|
||||
|
||||
latestRef.current = {
|
||||
cancelRefetch,
|
||||
enabled,
|
||||
error: query.error,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
guardOnFetching,
|
||||
hasNextPage: Boolean(query.hasNextPage),
|
||||
isFetching: query.isFetching ?? false,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoading: query.isLoading ?? false,
|
||||
}
|
||||
|
||||
const rootRef = useCallback((node: TRoot | null) => {
|
||||
setRootEl(node)
|
||||
}, [])
|
||||
|
||||
const sentinelRef = useCallback((node: TTarget | null) => {
|
||||
setSentinelEl(node)
|
||||
}, [])
|
||||
|
||||
const canLoad = enabled
|
||||
&& Boolean(query.hasNextPage)
|
||||
&& !query.isFetchingNextPage
|
||||
&& !(query.isLoading ?? false)
|
||||
&& !query.error
|
||||
&& !(guardOnFetching && (query.isFetching ?? false))
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoad)
|
||||
return
|
||||
|
||||
if (!sentinelEl)
|
||||
return
|
||||
|
||||
if (!useWindow && !rootEl)
|
||||
return
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined')
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
const latest = latestRef.current
|
||||
|
||||
if (!entry?.isIntersecting)
|
||||
return
|
||||
|
||||
if (!latest.enabled
|
||||
|| !latest.hasNextPage
|
||||
|| latest.isLoading
|
||||
|| latest.isFetchingNextPage
|
||||
|| latest.error
|
||||
|| (latest.guardOnFetching && latest.isFetching)
|
||||
|| loadingLockRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingLockRef.current = true
|
||||
|
||||
const nextPage = latest.fetchNextPage({
|
||||
cancelRefetch: latest.cancelRefetch,
|
||||
})
|
||||
|
||||
void Promise.resolve(nextPage).finally(() => {
|
||||
loadingLockRef.current = false
|
||||
})
|
||||
}, {
|
||||
root: useWindow ? null : rootEl,
|
||||
rootMargin,
|
||||
threshold,
|
||||
})
|
||||
|
||||
observer.observe(sentinelEl)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [canLoad, rootEl, rootMargin, sentinelEl, threshold, useWindow])
|
||||
|
||||
return {
|
||||
rootEl,
|
||||
rootRef,
|
||||
sentinelEl,
|
||||
sentinelRef,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user