refactor(web): reuse infinite scroll hook in deployments (#37825)

This commit is contained in:
Stephen Zhou 2026-06-23 22:22:09 +08:00 committed by GitHub
parent 5b453069d1
commit 50b3228bc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 738 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 })] : []),

View File

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

View File

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

View File

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

View File

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

View File

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

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