diff --git a/packages/contracts/generated/enterprise/orpc.gen.ts b/packages/contracts/generated/enterprise/orpc.gen.ts index 61503a7f742..764cea4fdc1 100644 --- a/packages/contracts/generated/enterprise/orpc.gen.ts +++ b/packages/contracts/generated/enterprise/orpc.gen.ts @@ -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', diff --git a/packages/contracts/generated/enterprise/types.gen.ts b/packages/contracts/generated/enterprise/types.gen.ts index 600f8975678..882a85465b9 100644 --- a/packages/contracts/generated/enterprise/types.gen.ts +++ b/packages/contracts/generated/enterprise/types.gen.ts @@ -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 + unsupportedToolProviders: Array } 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 + permissions?: Array } export type ResetMemberPasswordReply = { @@ -1813,7 +1828,7 @@ export type SetDefaultWorkspaceReq = { export type Subject = { subjectId?: string - subjectType?: SubjectType + subjectType?: string accountData?: SubjectAccountData groupData?: SubjectGroupData } diff --git a/packages/contracts/generated/enterprise/zod.gen.ts b/packages/contracts/generated/enterprise/zod.gen.ts index d7a42b35d4c..85f74b22121 100644 --- a/packages/contracts/generated/enterprise/zod.gen.ts +++ b/packages/contracts/generated/enterprise/zod.gen.ts @@ -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({ diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index c8bce5401e6..7cfa29fe97a 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -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 }, ], }, }, diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 18c28d12757..13aa079092a 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -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[] = [] 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 diff --git a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx index 3a99e1418a7..d0a1eaae638 100644 --- a/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx +++ b/web/features/deployments/create-guide/ui/__tests__/source-step.spec.tsx @@ -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() @@ -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() + + 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() + }) }) diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index d75b9965773..be2dcff89c1 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -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(sourceAppsQuery, { + enabled: !sourceAppsLoading, + rootMargin: '0px 0px 160px 0px', + threshold: 0.1, + }) return ( -
+
{sourceAppsLoading ? : sourceApps.length === 0 @@ -208,20 +214,12 @@ function SourceAppList() { onSelect={() => selectSourceApp(app)} /> ))} - {sourceAppsQuery.hasNextPage && ( -
- + {sourceAppsQuery.isFetchingNextPage && ( +
+ {t('createModal.loadingApps')}
)} + {sourceAppsQuery.hasNextPage && )}
diff --git a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx index dbf553b8d1f..d77bfde6f90 100644 --- a/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx +++ b/web/features/deployments/create-release/ui/__tests__/source-app-picker.spec.tsx @@ -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() + }) }) diff --git a/web/features/deployments/create-release/ui/source-app-picker.tsx b/web/features/deployments/create-release/ui/source-app-picker.tsx index 633cea433fc..2f3c06fb0bd 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -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(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 }: { />
-
+
{(isLoading || isFetchingNextPage) && apps.length === 0 && } {(app: App) => ( @@ -214,20 +219,12 @@ export function SourceAppPicker({ value, onChange, disabled = false }: { {t('createModal.appSearchEmpty')} )} - {hasNextPage && ( -
- + {isFetchingNextPage && apps.length > 0 && ( +
+ {t('createModal.loadingApps')}
)} + {hasNextPage &&
diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts b/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts index 4a6bce99f87..a2ec5ca6a50 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts +++ b/web/features/deployments/detail/settings-tab/access/__tests__/access-policy.spec.ts @@ -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) diff --git a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx index 891f117efea..9c21ac5083d 100644 --- a/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/settings-tab/access/__tests__/permissions.spec.tsx @@ -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( + , + ) + + 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( + , + ) + + 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( = { 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, })), ] diff --git a/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx b/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx index 25913c33543..1fc6c4adb7f 100644 --- a/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx +++ b/web/features/deployments/detail/settings-tab/access/permission-row-components.tsx @@ -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 })] : []), diff --git a/web/features/deployments/detail/versions-tab/state.ts b/web/features/deployments/detail/versions-tab/state.ts index 5cf772bc54f..4d90228a3af 100644 --- a/web/features/deployments/detail/versions-tab/state.ts +++ b/web/features/deployments/detail/versions-tab/state.ts @@ -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(undefined) -export const releaseHistoryQueryAtom = atomWithQuery((get) => { +export const releaseHistoryQueryAtom = atomWithQuery((get) => { const appInstanceId = get(deploymentRouteAppInstanceIdAtom) const currentPage = get(releaseHistoryCurrentPageAtom) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index bd4aef7b5fe..2e24efb7ef6 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -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) { - const rows = data?.pages?.flatMap(page => - page.appInstanceSummaries.flatMap(summary => summary.environmentDeployments), - ) ?? [] - - return deploymentStatusPollingInterval(rows) -} - -export const deploymentsListQueryAtom = atomWithInfiniteQuery< - ListAppInstanceSummariesResponse, - Error, - InfiniteData, - 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) + }, }) }) diff --git a/web/features/deployments/list/ui/shell.tsx b/web/features/deployments/list/ui/shell.tsx index d564fa0114e..2aea1d6b3db 100644 --- a/web/features/deployments/list/ui/shell.tsx +++ b/web/features/deployments/list/ui/shell.tsx @@ -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(null) - const anchorRef = useRef(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(deploymentsListQuery) return ( -
+
@@ -215,10 +189,8 @@ export function DeploymentsListShell() { /> ))} {isFetchingNextPage && } + - -
-
) } diff --git a/web/features/deployments/nav/state.ts b/web/features/deployments/nav/state.ts index 558c14a1a8c..b9242c38637 100644 --- a/web/features/deployments/nav/state.ts +++ b/web/features/deployments/nav/state.ts @@ -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, - QueryKey, - number ->((get) => { +export const deploymentsNavListQueryAtom = atomWithInfiniteQuery((get) => { const isActive = get(deploymentsRouteActiveAtom) return consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({ diff --git a/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts new file mode 100644 index 00000000000..83b0fa7a6d0 --- /dev/null +++ b/web/features/deployments/shared/hooks/__tests__/use-infinite-scroll.spec.ts @@ -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 + + 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(query, options) + + return createElement( + 'div', + { 'ref': rootRef, 'data-testid': 'scroll-root' }, + createElement('div', { 'ref': sentinelRef, 'data-testid': 'scroll-sentinel' }), + ) +} + +function createInfiniteScrollQuery(overrides: Partial = {}): 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) + }) +}) diff --git a/web/features/deployments/shared/hooks/use-infinite-scroll.ts b/web/features/deployments/shared/hooks/use-infinite-scroll.ts new file mode 100644 index 00000000000..ba865c1dd7e --- /dev/null +++ b/web/features/deployments/shared/hooks/use-infinite-scroll.ts @@ -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 + 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 = { + rootEl: TRoot | null + rootRef: RefCallback + sentinelEl: TTarget | null + sentinelRef: RefCallback +} + +export function useInfiniteScroll< + TRoot extends Element = HTMLDivElement, + TTarget extends Element = HTMLDivElement, +>( + query: InfiniteScrollQueryResult, + options: UseInfiniteScrollOptions = {}, +): UseInfiniteScrollResult { + const { + cancelRefetch = false, + enabled = true, + guardOnFetching = true, + rootMargin = '0px 0px 300px 0px', + threshold = 0, + useWindow = false, + } = options + + const [rootEl, setRootEl] = useState(null) + const [sentinelEl, setSentinelEl] = useState(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, + } +}