refactor(web): reduce query atom subscriptions (#38521)

This commit is contained in:
Stephen Zhou 2026-07-07 23:10:58 +08:00 committed by GitHub
parent 09c5c5e5ed
commit 5308b95aff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 420 additions and 193 deletions

View File

@ -54,6 +54,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- `jotai-tanstack-query` query atoms do not support TanStack Query tracked properties. A component that reads `useAtomValue(queryAtom)` subscribes to the whole query result, even if it only accesses `data`, `isLoading`, or `isError`. Export field-specific derived atoms and have components read the exact fields they render; use `selectAtom(queryAtom, result => result.field)` for query-result fields so unchanged selections do not notify subscribers. Keep direct `useAtomValue(queryAtom)` only when the component or hook genuinely needs the full observer result.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.

View File

@ -4,6 +4,7 @@ import type { Getter } from 'jotai/vanilla'
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { encodeDslContent } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { effectiveMethodAtom, instanceNameAtom, submissionUnsupportedDslNodesAtom } from './primitives'
@ -53,6 +54,11 @@ export const deployableEnvironmentsQueryAtom = atomWithQuery((get) => {
})
})
export const deployableEnvironmentsDataAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.data)
export const deployableEnvironmentsIsErrorAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isError)
export const deployableEnvironmentsIsLoadingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isLoading)
export const deployableEnvironmentsIsFetchingAtom = selectAtom(deployableEnvironmentsQueryAtom, query => query.isFetching)
const precheckReleaseQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
@ -88,17 +94,22 @@ const precheckReleaseQueryAtom = atomWithQuery((get) => {
return precheckReleaseQueryOptions
})
const precheckReleaseDataAtom = selectAtom(precheckReleaseQueryAtom, query => query.data)
const precheckReleaseIsSuccessAtom = selectAtom(precheckReleaseQueryAtom, query => query.isSuccess)
const precheckReleaseIsLoadingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isLoading)
const precheckReleaseIsFetchingAtom = selectAtom(precheckReleaseQueryAtom, query => query.isFetching)
function precheckReleaseReady(get: Getter) {
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
const precheckRelease = get(precheckReleaseDataAtom)
return sourceReady(get)
&& precheckReleaseQuery.isSuccess
&& Boolean(precheckReleaseQuery.data?.canCreate)
&& (precheckReleaseQuery.data?.unsupportedNodes.length ?? 0) === 0
&& get(precheckReleaseIsSuccessAtom)
&& Boolean(precheckRelease?.canCreate)
&& (precheckRelease?.unsupportedNodes.length ?? 0) === 0
&& get(submissionUnsupportedDslNodesAtom).length === 0
}
export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const deploymentOptionsQueryAtom = atomWithQuery((get) => {
const method = get(effectiveMethodAtom)
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const dslContent = get(dslContentAtom)
@ -133,6 +144,12 @@ export const deploymentOptionsQueryAtom = atomWithQuery((get) => {
return deploymentOptionsQueryOptions
})
export const deploymentOptionsDataAtom = selectAtom(deploymentOptionsQueryAtom, query => query.data)
export const deploymentOptionsIsErrorAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isError)
export const deploymentOptionsIsLoadingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isLoading)
export const deploymentOptionsIsFetchingAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isFetching)
const deploymentOptionsIsSuccessAtom = selectAtom(deploymentOptionsQueryAtom, query => query.isSuccess)
export const unsupportedDslNodesAtom = atom((get) => {
const submissionUnsupportedDslNodes = get(submissionUnsupportedDslNodesAtom)
if (submissionUnsupportedDslNodes.length > 0)
@ -141,7 +158,7 @@ export const unsupportedDslNodesAtom = atom((get) => {
if (!sourceReady(get))
return []
return get(precheckReleaseQueryAtom).data?.unsupportedNodes ?? []
return get(precheckReleaseDataAtom)?.unsupportedNodes ?? []
})
const precheckReleaseReadyAtom = atom((get) => {
@ -149,21 +166,17 @@ const precheckReleaseReadyAtom = atom((get) => {
})
export const deploymentOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
return sourceReady(get)
&& get(precheckReleaseReadyAtom)
&& deploymentOptionsQuery.isSuccess
&& get(deploymentOptionsIsSuccessAtom)
})
export const deploymentOptionsContentCheckedAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const precheckReleaseQuery = get(precheckReleaseQueryAtom)
const isLoadingOptions = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isCheckingReleaseContent = precheckReleaseQuery.isLoading || (precheckReleaseQuery.isFetching && !precheckReleaseQuery.data)
const isLoadingOptions = get(deploymentOptionsIsLoadingAtom) || (get(deploymentOptionsIsFetchingAtom) && !get(deploymentOptionsDataAtom))
const isCheckingReleaseContent = get(precheckReleaseIsLoadingAtom) || (get(precheckReleaseIsFetchingAtom) && !get(precheckReleaseDataAtom))
if (!sourceReady(get) || isCheckingReleaseContent || isLoadingOptions)
return false
return get(precheckReleaseReadyAtom) && deploymentOptionsQuery.isSuccess
return get(precheckReleaseReadyAtom) && get(deploymentOptionsIsSuccessAtom)
})

View File

@ -5,6 +5,7 @@ import type { WorkflowSourceApp } from './types'
import { keepPreviousData, queryOptions } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { dslAppName, isWorkflowDsl } from '@/features/deployments/shared/domain/dsl'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
@ -92,18 +93,28 @@ export const sourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const sourceAppsDataAtom = selectAtom(sourceAppsQueryAtom, query => query.data)
export const sourceAppsErrorAtom = selectAtom(sourceAppsQueryAtom, query => query.error)
export const sourceAppsFetchNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.fetchNextPage)
export const sourceAppsHasNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.hasNextPage)
export const sourceAppsIsFetchingAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetching)
export const sourceAppsIsFetchingNextPageAtom = selectAtom(sourceAppsQueryAtom, query => query.isFetchingNextPage)
export const sourceAppsIsLoadingAtom = selectAtom(sourceAppsQueryAtom, query => query.isLoading)
export const sourceAppsIsPlaceholderDataAtom = selectAtom(sourceAppsQueryAtom, query => query.isPlaceholderData)
export const sourceAppsAtom = atom((get) => {
return (get(sourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
})
export const effectiveSelectedAppAtom = atom((get) => {
const selectedApp = get(selectedAppAtom)
if (selectedApp)
return selectedApp
const sourceAppsQuery = get(sourceAppsQueryAtom)
if (sourceAppsQuery.isPlaceholderData)
if (get(sourceAppsIsPlaceholderDataAtom))
return undefined
const sourceApps = (sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []) as WorkflowSourceApp[]
return sourceApps[0]
return get(sourceAppsAtom)[0]
})
export function sourceReady(get: Getter) {

View File

@ -22,7 +22,7 @@ import {
selectedEnvironmentIdAtom,
submissionUnsupportedDslNodesAtom,
} from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom } from './queries'
import { deployableEnvironmentsQueryAtom, deploymentOptionsDataAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, effectiveSelectedAppAtom } from './source'
import {
@ -75,7 +75,7 @@ export const createDeploymentGuideSubmissionAtom = atom(null, async (get, set, {
const effectiveSelectedApp = get(effectiveSelectedAppAtom)
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deploymentOptions = get(deploymentOptionsQueryAtom).data?.options
const deploymentOptions = get(deploymentOptionsDataAtom)?.options
const envVarSlots = get(deploymentTargetEnvVarSlotsAtom)
const envVarValues = get(envVarValuesAtom)
const bindingSlots = get(deploymentTargetBindingSlotsAtom)

View File

@ -11,23 +11,21 @@ import {
import { dslEnvVarSlots } from '@/features/deployments/shared/domain/dsl'
import { environmentMatchesIdentifier } from './environment'
import { effectiveMethodAtom, envVarValuesAtom, manualBindingSelectionsAtom, selectedEnvironmentIdAtom } from './primitives'
import { deployableEnvironmentsQueryAtom, deploymentOptionsQueryAtom, deploymentOptionsReadyAtom } from './queries'
import { deployableEnvironmentsDataAtom, deploymentOptionsDataAtom, deploymentOptionsReadyAtom } from './queries'
import { submittedReleaseReadyAtom } from './release'
import { dslContentAtom, sourceReady } from './source'
import { envVarSelectionReady } from './utils'
export const deployableEnvironmentsAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
const deployableEnvironments = get(deployableEnvironmentsDataAtom)
return sourceReady(get)
? deployableEnvironmentsQuery.data?.environments ?? []
? deployableEnvironments?.environments ?? []
: []
})
const deployableEnvironmentsReadyAtom = atom((get) => {
const deployableEnvironmentsQuery = get(deployableEnvironmentsQueryAtom)
return sourceReady(get) && deployableEnvironmentsQuery.isSuccess
return sourceReady(get) && Boolean(get(deployableEnvironmentsDataAtom))
})
export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
@ -35,10 +33,10 @@ export const effectiveSelectedEnvironmentIdAtom = atom((get) => {
})
export const deploymentTargetBindingSlotsAtom = atom((get) => {
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const deploymentOptions = get(deploymentOptionsDataAtom)
return sourceReady(get)
? deploymentOptionsQuery.data?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
? deploymentOptions?.options?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
})
@ -59,8 +57,8 @@ export const requiredBindingsReadyAtom = atom((get) => {
export const deploymentTargetEnvVarSlotsAtom = atom((get) => {
const method = get(effectiveMethodAtom)
const deploymentOptionsQuery = get(deploymentOptionsQueryAtom)
const slots = sourceReady(get) ? deploymentOptionsQuery.data?.options?.envVarSlots : undefined
const deploymentOptions = get(deploymentOptionsDataAtom)
const slots = sourceReady(get) ? deploymentOptions?.options?.envVarSlots : undefined
const dslContent = get(dslContentAtom)
// Deployment options own the canonical slot list; DSL metadata only enriches import-DSL defaults.

View File

@ -5,12 +5,13 @@ import { SourceStepContent } from '../source-step'
const mocks = vi.hoisted(() => {
const sourceAppsQuery = {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
fetchNextPage: vi.fn(),
}
return {
@ -46,6 +47,14 @@ vi.mock('@/features/deployments/create-guide/state/source', async () => {
dslUnsupportedModeAtom: atom(false),
effectiveSelectedAppAtom: atom(undefined),
isReadingDslAtom: atom(false),
sourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)),
sourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
sourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
sourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
sourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
sourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage),
sourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
sourceAppsIsPlaceholderDataAtom: atom(() => mocks.sourceAppsQuery.isPlaceholderData),
sourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
})
@ -80,12 +89,13 @@ describe('SourceStepContent', () => {
vi.clearAllMocks()
Object.assign(mocks.sourceAppsQuery, {
data: { pages: [{ data: [] }] },
error: null,
fetchNextPage: vi.fn(),
hasNextPage: false,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
isPlaceholderData: false,
fetchNextPage: vi.fn(),
})
})
@ -114,7 +124,13 @@ describe('SourceStepContent', () => {
render(<SourceStepContent />)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
mocks.sourceAppsQuery,
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: expect.any(Boolean),
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,

View File

@ -21,7 +21,14 @@ import {
dslUnsupportedModeAtom,
effectiveSelectedAppAtom,
isReadingDslAtom,
sourceAppsQueryAtom,
sourceAppsAtom,
sourceAppsErrorAtom,
sourceAppsFetchNextPageAtom,
sourceAppsHasNextPageAtom,
sourceAppsIsFetchingAtom,
sourceAppsIsFetchingNextPageAtom,
sourceAppsIsLoadingAtom,
sourceAppsIsPlaceholderDataAtom,
} from '@/features/deployments/create-guide/state/source'
import {
continueFromSourceAtom,
@ -189,10 +196,23 @@ function SourceAppList() {
const { t } = useTranslation('deployments')
const selectSourceApp = useSetAtom(selectSourceAppAtom)
const effectiveSelectedApp = useAtomValue(effectiveSelectedAppAtom)
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, {
const sourceApps = useAtomValue(sourceAppsAtom)
const sourceAppsError = useAtomValue(sourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(sourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(sourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(sourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(sourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(sourceAppsIsLoadingAtom)
const sourceAppsIsPlaceholderData = useAtomValue(sourceAppsIsPlaceholderDataAtom)
const sourceAppsLoading = sourceAppsIsLoading || sourceAppsIsPlaceholderData || (sourceAppsIsFetching && sourceApps.length === 0)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
}, {
enabled: !sourceAppsLoading,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
@ -218,12 +238,12 @@ function SourceAppList() {
onSelect={() => selectSourceApp(app)}
/>
))}
{sourceAppsQuery.isFetchingNextPage && (
{sourceAppsIsFetchingNextPage && (
<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" />}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
</div>
)}
</div>

View File

@ -16,8 +16,13 @@ import {
stepAtom,
} from '@/features/deployments/create-guide/state/primitives'
import {
deployableEnvironmentsQueryAtom,
deploymentOptionsQueryAtom,
deployableEnvironmentsIsErrorAtom,
deployableEnvironmentsIsFetchingAtom,
deployableEnvironmentsIsLoadingAtom,
deploymentOptionsDataAtom,
deploymentOptionsIsErrorAtom,
deploymentOptionsIsFetchingAtom,
deploymentOptionsIsLoadingAtom,
unsupportedDslNodesAtom,
} from '@/features/deployments/create-guide/state/queries'
import {
@ -72,11 +77,12 @@ export function TargetStepContent() {
function TargetEnvironmentSection() {
const { t } = useTranslation('deployments')
const environmentsQuery = useAtomValue(deployableEnvironmentsQueryAtom)
const environmentsIsError = useAtomValue(deployableEnvironmentsIsErrorAtom)
const environmentsIsFetching = useAtomValue(deployableEnvironmentsIsFetchingAtom)
const environmentsIsLoading = useAtomValue(deployableEnvironmentsIsLoadingAtom)
const environments = useAtomValue(deployableEnvironmentsAtom)
const effectiveSelectedEnvironmentId = useAtomValue(effectiveSelectedEnvironmentIdAtom)
const isEnvironmentError = environmentsQuery.isError
const isEnvironmentLoading = environmentsQuery.isLoading || (environmentsQuery.isFetching && !environmentsQuery.data)
const isEnvironmentLoading = environmentsIsLoading || (environmentsIsFetching && environments.length === 0)
const selectEnvironment = useSetAtom(selectedEnvironmentIdAtom)
const hasEnvironmentOptions = environments.length > 0
@ -102,7 +108,7 @@ function TargetEnvironmentSection() {
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{isEnvironmentError
{environmentsIsError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
@ -144,11 +150,14 @@ function EnvironmentOptionRow({ environment }: {
function TargetBindingSection() {
const { t } = useTranslation('deployments')
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const bindingSlots = useAtomValue(deploymentTargetBindingSlotsAtom)
const bindingSelections = useAtomValue(deploymentTargetBindingSelectionsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isBindingError = deploymentOptionsIsError
const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
const selectBinding = useSetAtom(selectBindingAtom)
const unsupportedDslNodes = useAtomValue(unsupportedDslNodesAtom)
const shouldRender = !(isBindingError && unsupportedDslNodes.length > 0)
@ -196,10 +205,13 @@ function TargetEnvVarSection() {
const { t } = useTranslation('deployments')
const setEnvVar = useSetAtom(setEnvVarAtom)
const envVarValues = useAtomValue(envVarValuesAtom)
const deploymentOptionsQuery = useAtomValue(deploymentOptionsQueryAtom)
const deploymentOptions = useAtomValue(deploymentOptionsDataAtom)
const deploymentOptionsIsError = useAtomValue(deploymentOptionsIsErrorAtom)
const deploymentOptionsIsFetching = useAtomValue(deploymentOptionsIsFetchingAtom)
const deploymentOptionsIsLoading = useAtomValue(deploymentOptionsIsLoadingAtom)
const envVarSlots = useAtomValue(deploymentTargetEnvVarSlotsAtom)
const isBindingError = deploymentOptionsQuery.isError
const isBindingLoading = deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data)
const isBindingError = deploymentOptionsIsError
const isBindingLoading = deploymentOptionsIsLoading || (deploymentOptionsIsFetching && !deploymentOptions)
if (isBindingLoading || isBindingError)
return null

View File

@ -20,6 +20,7 @@ import {
atomWithQuery,
queryClientAtom,
} from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import * as z from 'zod'
import { consoleQuery } from '@/service/client'
import { normalizeAppPagination } from '@/service/use-apps'
@ -270,6 +271,18 @@ export const createReleaseSourceAppsQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const createReleaseSourceAppsDataAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.data)
export const createReleaseSourceAppsErrorAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.error)
export const createReleaseSourceAppsFetchNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.fetchNextPage)
export const createReleaseSourceAppsHasNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.hasNextPage)
export const createReleaseSourceAppsIsFetchingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetching)
export const createReleaseSourceAppsIsFetchingNextPageAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isFetchingNextPage)
export const createReleaseSourceAppsIsLoadingAtom = selectAtom(createReleaseSourceAppsQueryAtom, query => query.isLoading)
export const createReleaseSourceAppsAtom = atom((get) => {
return get(createReleaseSourceAppsDataAtom)?.pages.flatMap(page => page.data) ?? []
})
export const createReleaseDslContentAtom = atom((get) => {
return get(createReleaseDslFileContentQueryAtom).data ?? ''
})

View File

@ -35,6 +35,13 @@ vi.mock('@/features/deployments/create-release/state', async () => {
const { atom } = await import('jotai')
return {
createReleaseSourceAppsAtom: atom(() => mocks.sourceAppsQuery.data.pages.flatMap(page => page.data)),
createReleaseSourceAppsErrorAtom: atom(() => mocks.sourceAppsQuery.error),
createReleaseSourceAppsFetchNextPageAtom: atom(() => mocks.sourceAppsQuery.fetchNextPage),
createReleaseSourceAppsHasNextPageAtom: atom(() => mocks.sourceAppsQuery.hasNextPage),
createReleaseSourceAppsIsFetchingAtom: atom(() => mocks.sourceAppsQuery.isFetching),
createReleaseSourceAppsIsFetchingNextPageAtom: atom(() => mocks.sourceAppsQuery.isFetchingNextPage),
createReleaseSourceAppsIsLoadingAtom: atom(() => mocks.sourceAppsQuery.isLoading),
createReleaseSourceAppSearchTextAtom: atom(''),
createReleaseSourceAppsQueryAtom: atom(mocks.sourceAppsQuery),
}
@ -98,7 +105,13 @@ describe('SourceAppPicker', () => {
renderSourceAppPicker(false)
expect(mocks.useInfiniteScroll).toHaveBeenCalledWith(
mocks.sourceAppsQuery,
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
enabled: false,
rootMargin: '0px 0px 160px 0px',
@ -110,7 +123,13 @@ describe('SourceAppPicker', () => {
await waitFor(() => {
expect(mocks.useInfiniteScroll).toHaveBeenLastCalledWith(
mocks.sourceAppsQuery,
expect.objectContaining({
fetchNextPage: expect.any(Function),
hasNextPage: true,
isFetching: false,
isFetchingNextPage: false,
isLoading: false,
}),
expect.objectContaining({
enabled: true,
rootMargin: '0px 0px 160px 0px',

View File

@ -21,8 +21,14 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useInfiniteScroll } from '@/features/deployments/shared/hooks/use-infinite-scroll'
import { TitleTooltip } from '../../shared/components/title-tooltip'
import {
createReleaseSourceAppsAtom,
createReleaseSourceAppSearchTextAtom,
createReleaseSourceAppsQueryAtom,
createReleaseSourceAppsErrorAtom,
createReleaseSourceAppsFetchNextPageAtom,
createReleaseSourceAppsHasNextPageAtom,
createReleaseSourceAppsIsFetchingAtom,
createReleaseSourceAppsIsFetchingNextPageAtom,
createReleaseSourceAppsIsLoadingAtom,
} from '../state'
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
@ -134,21 +140,26 @@ 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,
hasNextPage,
} = sourceAppsQuery
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(sourceAppsQuery, {
const apps = useAtomValue(createReleaseSourceAppsAtom)
const sourceAppsError = useAtomValue(createReleaseSourceAppsErrorAtom)
const sourceAppsFetchNextPage = useAtomValue(createReleaseSourceAppsFetchNextPageAtom)
const sourceAppsHasNextPage = useAtomValue(createReleaseSourceAppsHasNextPageAtom)
const sourceAppsIsFetching = useAtomValue(createReleaseSourceAppsIsFetchingAtom)
const sourceAppsIsFetchingNextPage = useAtomValue(createReleaseSourceAppsIsFetchingNextPageAtom)
const sourceAppsIsLoading = useAtomValue(createReleaseSourceAppsIsLoadingAtom)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: sourceAppsError,
fetchNextPage: sourceAppsFetchNextPage,
hasNextPage: sourceAppsHasNextPage,
isFetching: sourceAppsIsFetching,
isFetchingNextPage: sourceAppsIsFetchingNextPage,
isLoading: sourceAppsIsLoading,
}, {
enabled: isShow && !disabled,
rootMargin: '0px 0px 160px 0px',
threshold: 0.1,
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
@ -208,23 +219,23 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
</ComboboxInputGroup>
</div>
<div ref={rootRef} className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
{(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
{!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{isFetchingNextPage && apps.length > 0 && (
{sourceAppsIsFetchingNextPage && 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" />}
{sourceAppsHasNextPage && <div ref={sentinelRef} aria-hidden="true" className="h-px" />}
</div>
</div>
</ComboboxContent>

View File

@ -18,6 +18,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithMutation, atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { envVarBindingSlotFromContract } from '../../shared/components/env-var-bindings-utils'
import {
@ -81,6 +82,10 @@ export const releaseDeploymentViewQueryAtom = atomWithQuery((get) => {
})
})
export const releaseDeploymentViewAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.data)
export const releaseDeploymentViewIsLoadingAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isLoading)
export const releaseDeploymentViewIsErrorAtom = selectAtom(releaseDeploymentViewQueryAtom, query => query.isError)
const selectedEnvIdAtom = atom<string | undefined>(undefined)
const selectedReleaseIdAtom = atom<string | undefined>(undefined)
const manualBindingsAtom = atom<RuntimeCredentialBindingSelections>({})
@ -223,44 +228,47 @@ const releaseDeploymentOptionsQueryAtom = atomWithQuery((get) => {
})
})
export const deployBindingSlotsAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseDeploymentOptionsAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.data)
const releaseDeploymentOptionsIsLoadingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isLoading)
const releaseDeploymentOptionsIsFetchingAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isFetching)
const releaseDeploymentOptionsIsErrorAtom = selectAtom(releaseDeploymentOptionsQueryAtom, query => query.isError)
return deploymentOptionsQuery.data?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
export const deployBindingSlotsAtom = atom((get) => {
const deploymentOptions = get(releaseDeploymentOptionsAtom)
return deploymentOptions?.options.credentialSlots.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
})
export const deployEnvVarSlotsAtom = atom((get): EnvVarBindingSlot[] => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const deploymentOptions = get(releaseDeploymentOptionsAtom)
return deploymentOptionsQuery.data?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
return deploymentOptions?.options.envVarSlots.flatMap((slot): EnvVarBindingSlot[] => {
const bindingSlot = envVarBindingSlotFromContract(slot)
return bindingSlot ? [bindingSlot] : []
}) ?? []
})
export const deployIsBindingOptionsLoadingAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& (deploymentOptionsQuery.isLoading || deploymentOptionsQuery.isFetching),
&& (get(releaseDeploymentOptionsIsLoadingAtom) || get(releaseDeploymentOptionsIsFetchingAtom)),
)
})
export const deployHasBindingOptionsErrorAtom = atom((get) => {
return get(releaseDeploymentOptionsQueryAtom).isError
return get(releaseDeploymentOptionsIsErrorAtom)
})
const deployIsBindingOptionsReadyAtom = atom((get) => {
const deploymentOptionsQuery = get(releaseDeploymentOptionsQueryAtom)
const releaseId = get(deployTargetReleaseIdAtom)
return Boolean(
releaseId
&& get(deployHasSelectedEnvironmentAtom)
&& deploymentOptionsQuery.data
&& get(releaseDeploymentOptionsAtom)
&& !get(deployIsBindingOptionsLoadingAtom)
&& !get(deployHasBindingOptionsErrorAtom),
)

View File

@ -11,7 +11,7 @@ import { ScopeProvider } from 'jotai-scope'
import { useTranslation } from 'react-i18next'
import { EnvVarBindingsPanel } from '../../shared/components/env-var-bindings'
import { isAvailableDeploymentTarget } from '../../shared/domain/runtime-status'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewQueryAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import { canAttemptDeployAtom, canSubmitDeployAtom, closeDeployDrawerAtom, deployBindingSlotsAtom, deployEnvVarSlotsAtom, deployEnvVarValuesAtom, deployFormAppInstanceIdAtom, deployHasBindingOptionsErrorAtom, deployHasSelectedEnvironmentAtom, deployIsBindingOptionsLoadingAtom, deployReadyFormConfigAtom, deployReadyFormLocalAtoms, deployReleaseSubmissionAtom, deploySelectedBindingsAtom, deployShowValidationErrorsAtom, deployTargetReleaseIdAtom, isDeployReleaseSubmittingAtom, releaseDeploymentViewAtom, releaseDeploymentViewIsErrorAtom, releaseDeploymentViewIsLoadingAtom, selectDeployBindingAtom, setDeployEnvVarAtom, showDeployValidationErrorsAtom } from '../state'
import {
currentReleaseIdForEnvironment,
selectableDeployReleases,
@ -203,13 +203,15 @@ function DeployFormContent({
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseDeploymentViewQuery = useAtomValue(releaseDeploymentViewQueryAtom)
const deploymentView = useAtomValue(releaseDeploymentViewAtom)
const isLoading = useAtomValue(releaseDeploymentViewIsLoadingAtom)
const isError = useAtomValue(releaseDeploymentViewIsErrorAtom)
if (releaseDeploymentViewQuery.isLoading) {
if (isLoading) {
return <DeployFormSkeleton />
}
if (releaseDeploymentViewQuery.isError) {
if (isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
@ -217,7 +219,6 @@ function DeployFormContent({
)
}
const deploymentView = releaseDeploymentViewQuery.data
if (!deploymentView) {
return (
<div className="p-4 system-sm-regular text-text-destructive">

View File

@ -2,7 +2,11 @@ import type { AccessChannels, AccessEndpoint } from '@dify/contracts/enterprise/
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { accessSettingsQueryAtom } from '../../state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../../state'
import { AccessChannelsSection } from '../section'
const mockToggleAccessChannel = vi.hoisted(() => vi.fn())
@ -68,17 +72,17 @@ describe('AccessChannelsSection', () => {
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsQueryAtom) {
if (atom === accessSettingsAtom) {
return {
data: {
accessChannels: createAccessChannels(),
webAppEndpoints: [createEndpoint('https://app.example.com/webapp')],
cliEndpoint: createEndpoint('https://cli.example.com/entry'),
},
isLoading: false,
isError: false,
accessChannels: createAccessChannels(),
webAppEndpoints: [createEndpoint('https://app.example.com/webapp')],
cliEndpoint: createEndpoint('https://cli.example.com/entry'),
}
}
if (atom === accessSettingsIsLoadingAtom)
return false
if (atom === accessSettingsIsErrorAtom)
return false
return undefined
})
})

View File

@ -12,7 +12,11 @@ import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { CopyPill, EndpointRow } from '../../../shared/components/endpoint'
import { Section } from '../../../shared/components/section'
import { accessSettingsQueryAtom } from '../state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../state'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
@ -115,12 +119,12 @@ function ChannelRow({ info, children }: {
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const accessChannels = accessSettingsQuery.data?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettingsQuery.data?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettingsQuery.data?.cliEndpoint
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const accessSettings = useAtomValue(accessSettingsAtom)
const isLoading = useAtomValue(accessSettingsIsLoadingAtom)
const isError = useAtomValue(accessSettingsIsErrorAtom)
const accessChannels = accessSettings?.accessChannels
const webAppEndpoints: AccessEndpoint[] | undefined = accessSettings?.webAppEndpoints
const cliEndpoint: AccessEndpoint | undefined = accessSettings?.cliEndpoint
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl

View File

@ -5,7 +5,11 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { accessSettingsQueryAtom } from '../../state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../../state'
import { EnvironmentPermissionRow } from '../environment-permission-row'
import { AccessPermissionsSection } from '../section'
@ -236,15 +240,15 @@ describe('AccessPermissionsSection', () => {
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
if (atom === accessSettingsQueryAtom) {
if (atom === accessSettingsAtom) {
return {
data: {
environmentPolicies: [createEnvironmentAccessPolicy()],
},
isLoading: false,
isError: false,
environmentPolicies: [createEnvironmentAccessPolicy()],
}
}
if (atom === accessSettingsIsLoadingAtom)
return false
if (atom === accessSettingsIsErrorAtom)
return false
return undefined
})
})

View File

@ -7,7 +7,11 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/components/empty-state'
import { Section } from '../../../shared/components/section'
import { accessSettingsQueryAtom } from '../state'
import {
accessSettingsAtom,
accessSettingsIsErrorAtom,
accessSettingsIsLoadingAtom,
} from '../state'
import { EnvironmentPermissionRow } from './environment-permission-row'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
@ -28,10 +32,10 @@ function AccessPermissionsSkeleton() {
export function AccessPermissionsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const accessSettingsQuery = useAtomValue(accessSettingsQueryAtom)
const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettingsQuery.data?.environmentPolicies
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const accessSettings = useAtomValue(accessSettingsAtom)
const isLoading = useAtomValue(accessSettingsIsLoadingAtom)
const isError = useAtomValue(accessSettingsIsErrorAtom)
const environmentPolicies: EnvironmentAccessPolicy[] | undefined = accessSettings?.environmentPolicies
const policyRows = environmentPolicies ?? []
return (

View File

@ -2,6 +2,7 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -17,3 +18,7 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const accessSettingsAtom = selectAtom(accessSettingsQueryAtom, query => query.data)
export const accessSettingsIsLoadingAtom = selectAtom(accessSettingsQueryAtom, query => query.isLoading)
export const accessSettingsIsErrorAtom = selectAtom(accessSettingsQueryAtom, query => query.isError)

View File

@ -16,7 +16,11 @@ import { ApiKeyGenerateMenu } from '../api-keys/api-key-generate-menu'
import { ApiKeyList } from '../api-keys/api-key-list'
import { CreatedApiTokenDialog } from '../api-keys/created-token-dialog'
import { DeveloperApiDocsDrawer } from '../docs/docs-drawer'
import { developerApiSettingsQueryAtom } from '../state'
import {
developerApiSettingsAtom,
developerApiSettingsIsErrorAtom,
developerApiSettingsIsLoadingAtom,
} from '../state'
import { DeveloperApiSkeleton } from './skeleton'
type CreatedApiToken = {
@ -86,21 +90,23 @@ export function DeveloperApiSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const developerApiSettings = useAtomValue(developerApiSettingsAtom)
const isLoading = useAtomValue(developerApiSettingsIsLoadingAtom)
const isError = useAtomValue(developerApiSettingsIsErrorAtom)
const accessChannels = developerApiSettings?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const environments = developerApiSettingsQuery.data?.environments ?? []
const apiUrl = developerApiSettings?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettings?.apiKeys ?? []
const environments = developerApiSettings?.environments ?? []
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
if (developerApiSettingsQuery.isLoading)
if (isLoading)
return <DeveloperApiSkeleton />
if (developerApiSettingsQuery.isError || !appInstanceId)
if (isError || !appInstanceId)
return <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
if (!apiEnabled) {

View File

@ -7,7 +7,11 @@ import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { developerApiSettingsQueryAtom } from './state'
import {
developerApiSettingsAtom,
developerApiSettingsIsErrorAtom,
developerApiSettingsIsLoadingAtom,
} from './state'
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
@ -43,11 +47,13 @@ function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const developerApiSettings = useAtomValue(developerApiSettingsAtom)
const isLoading = useAtomValue(developerApiSettingsIsLoadingAtom)
const isError = useAtomValue(developerApiSettingsIsErrorAtom)
const accessChannels = developerApiSettings?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
if (isLoading)
return <SwitchSkeleton />
return (
@ -58,7 +64,7 @@ export function DeveloperApiHeaderSwitch() {
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
disabled={isError}
/>
</div>
)

View File

@ -2,6 +2,7 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -17,3 +18,7 @@ export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsAtom = selectAtom(developerApiSettingsQueryAtom, query => query.data)
export const developerApiSettingsIsLoadingAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isLoading)
export const developerApiSettingsIsErrorAtom = selectAtom(developerApiSettingsQueryAtom, query => query.isError)

View File

@ -20,7 +20,11 @@ import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { TitleTooltip } from '../shared/components/title-tooltip'
import { deploymentDetailAppInstanceQueryAtom } from './state'
import {
deploymentDetailAppInstanceAtom,
deploymentDetailAppInstanceIsErrorAtom,
deploymentDetailAppInstanceIsLoadingAtom,
} from './state'
type TabDef = {
key: InstanceDetailTabKey
@ -95,10 +99,12 @@ function DeploymentDetailInstanceInfo({ appInstanceId, expand }: {
expand: boolean
}) {
const { t } = useTranslation('deployments')
const overviewQuery = useAtomValue(deploymentDetailAppInstanceQueryAtom)
const app = overviewQuery.data?.appInstance
const isLoading = !app && overviewQuery.isLoading
const isUnavailable = !app || overviewQuery.isError
const overview = useAtomValue(deploymentDetailAppInstanceAtom)
const isOverviewLoading = useAtomValue(deploymentDetailAppInstanceIsLoadingAtom)
const isOverviewError = useAtomValue(deploymentDetailAppInstanceIsErrorAtom)
const app = overview?.appInstance
const isLoading = !app && isOverviewLoading
const isUnavailable = !app || isOverviewError
const instanceName = app ? app.displayName : appInstanceId
return (

View File

@ -6,7 +6,8 @@ import { useTranslation } from 'react-i18next'
import { openDeployDrawerAtom } from '../../../deploy-drawer/state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import {
deploymentEnvironmentDeploymentsQueryAtom,
deploymentEnvironmentDeploymentsIsErrorAtom,
deploymentEnvironmentDeploymentsIsLoadingAtom,
deploymentRuntimeInstanceRowsAtom,
} from '../../state'
@ -34,10 +35,11 @@ export function NewDeploymentButton() {
}
export function NewDeploymentHeaderAction() {
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const isLoading = useAtomValue(deploymentEnvironmentDeploymentsIsLoadingAtom)
const hasError = useAtomValue(deploymentEnvironmentDeploymentsIsErrorAtom)
const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
if (isLoading || hasError || rows.length === 0)
return null
return <NewDeploymentButton />

View File

@ -14,7 +14,8 @@ import {
} from '../../shared/components/detail-table'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import {
deploymentEnvironmentDeploymentsQueryAtom,
deploymentEnvironmentDeploymentsIsErrorAtom,
deploymentEnvironmentDeploymentsIsLoadingAtom,
deploymentRuntimeInstanceRowsAtom,
} from '../state'
import { DeploymentEnvironmentList } from './environment-list/deployment-environment-list'
@ -89,10 +90,9 @@ function DeploymentEnvironmentListSkeleton() {
export function DeploymentInstances() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const isLoading = useAtomValue(deploymentEnvironmentDeploymentsIsLoadingAtom)
const hasError = useAtomValue(deploymentEnvironmentDeploymentsIsErrorAtom)
const rows = useAtomValue(deploymentRuntimeInstanceRowsAtom)
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">

View File

@ -9,7 +9,11 @@ import { hasRuntimeInstanceDeployment } from '../../shared/domain/runtime-status
import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './access-summary/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-status/environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './release-summary/release-hero'
import { deploymentOverviewQueryAtom } from './state'
import {
deploymentOverviewAtom,
deploymentOverviewIsErrorAtom,
deploymentOverviewIsLoadingAtom,
} from './state'
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
@ -63,13 +67,14 @@ function OverviewLoadingSkeleton() {
export function DeploymentOverview() {
const { t } = useTranslation('deployments')
const overviewQuery = useAtomValue(deploymentOverviewQueryAtom)
const overview = overviewQuery.data
const overview = useAtomValue(deploymentOverviewAtom)
const isLoading = useAtomValue(deploymentOverviewIsLoadingAtom)
const isError = useAtomValue(deploymentOverviewIsErrorAtom)
if (overviewQuery.isLoading)
if (isLoading)
return <OverviewLoadingSkeleton />
if (overviewQuery.isError) {
if (isError) {
return (
<OverviewLayout>
<DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>

View File

@ -2,6 +2,7 @@
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -17,3 +18,7 @@ export const deploymentOverviewQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const deploymentOverviewAtom = selectAtom(deploymentOverviewQueryAtom, query => query.data)
export const deploymentOverviewIsLoadingAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isLoading)
export const deploymentOverviewIsErrorAtom = selectAtom(deploymentOverviewQueryAtom, query => query.isError)

View File

@ -41,8 +41,10 @@ vi.mock('../state', async (importOriginal) => {
return {
...actual,
deployReleaseMenuEnvironmentDeploymentsQueryAtom: atom(environmentDeploymentsErrorResult()),
deployReleaseMenuAppInstanceQueryAtom: atom(appInstanceResult()),
deployReleaseMenuEnvironmentDeploymentsAtom: atom(undefined),
deployReleaseMenuEnvironmentDeploymentsIsErrorAtom: atom(true),
deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom: atom(false),
deployReleaseMenuAppInstanceNameAtom: atom('Deployment 1'),
}
})
@ -82,24 +84,6 @@ function createRelease(): Release {
}
}
function environmentDeploymentsErrorResult() {
return {
isError: true,
isLoading: false,
data: undefined,
}
}
function appInstanceResult() {
return {
data: {
appInstance: {
displayName: 'Deployment 1',
},
},
}
}
describe('DeployReleaseMenu', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -28,8 +28,10 @@ import { EditReleaseDialog } from './edit-release-dialog'
import { exportReleaseDsl } from './release-dsl-export'
import {
deleteReleaseDialogOpenAtom,
deployReleaseMenuAppInstanceQueryAtom,
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
deployReleaseMenuAppInstanceNameAtom,
deployReleaseMenuEnvironmentDeploymentsAtom,
deployReleaseMenuEnvironmentDeploymentsIsErrorAtom,
deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom,
deployReleaseMenuOpenAtom,
openDeleteReleaseDialogAtom,
openEditReleaseDialogAtom,
@ -54,19 +56,21 @@ function DeployReleaseMenuContent({ onDeleted }: {
const setDeleteReleaseDialogOpen = useSetAtom(deleteReleaseDialogOpenAtom)
const openEditReleaseDialog = useSetAtom(openEditReleaseDialogAtom)
const openDeleteReleaseDialog = useSetAtom(openDeleteReleaseDialogAtom)
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
const appInstanceQuery = useAtomValue(deployReleaseMenuAppInstanceQueryAtom)
const environmentDeployments = useAtomValue(deployReleaseMenuEnvironmentDeploymentsAtom)
const environmentDeploymentsIsLoading = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom)
const environmentDeploymentsIsError = useAtomValue(deployReleaseMenuEnvironmentDeploymentsIsErrorAtom)
const appInstanceName = useAtomValue(deployReleaseMenuAppInstanceNameAtom)
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
const exportReleaseDslMutation = useMutation(mutationOptions({
mutationKey: ['deployments', 'release-dsl-export'],
mutationFn: (input: ExportReleaseDslInput) => exportReleaseDsl(input),
}))
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
const deploymentEnvironmentRows = environmentDeployments?.environmentDeployments ?? []
const environments = deploymentEnvironmentRows
.map(row => row.environment)
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
const deploymentRows = deploymentEnvironmentRows.filter(row => !isUndeployedDeploymentRow(row))
const targetRelease = releaseRows.find(release => release.id === releaseId)
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
if (!targetRelease)
return null
@ -74,8 +78,8 @@ function DeployReleaseMenuContent({ onDeleted }: {
const release = targetRelease
const targetReleaseName = release.displayName
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
const hasDeleteUsageCheckFailed = open && environmentDeploymentsQuery.isError
const isCheckingDeleteUsage = open && environmentDeploymentsIsLoading
const hasDeleteUsageCheckFailed = open && environmentDeploymentsIsError
const isReleaseInUse = deleteUsageCount > 0
const isDeletingRelease = deleteRelease.isPending
const isExportingDsl = exportReleaseDslMutation.isPending
@ -130,7 +134,7 @@ function DeployReleaseMenuContent({ onDeleted }: {
const groupedRows = buildDeployMenuSections({
environments,
environmentDeployments: environmentDeploymentsQuery.data?.environmentDeployments ?? [],
environmentDeployments: deploymentEnvironmentRows,
releaseRows,
releaseId,
targetRelease: release,

View File

@ -4,7 +4,7 @@ import type { Release } from '@dify/contracts/enterprise/types.gen'
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { atomWithLazy } from 'jotai/utils'
import { atomWithLazy, selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
@ -35,6 +35,19 @@ export const deployReleaseMenuEnvironmentDeploymentsQueryAtom = atomWithQuery((g
})
})
export const deployReleaseMenuEnvironmentDeploymentsAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.data,
)
export const deployReleaseMenuEnvironmentDeploymentsIsLoadingAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.isLoading,
)
export const deployReleaseMenuEnvironmentDeploymentsIsErrorAtom = selectAtom(
deployReleaseMenuEnvironmentDeploymentsQueryAtom,
query => query.isError,
)
export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
const menuOpen = get(deployReleaseMenuOpenAtom)
@ -49,6 +62,11 @@ export const deployReleaseMenuAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
export const deployReleaseMenuAppInstanceNameAtom = selectAtom(
deployReleaseMenuAppInstanceQueryAtom,
query => query.data?.appInstance.displayName,
)
export const openEditReleaseDialogAtom = atom(null, (_get, set) => {
set(deployReleaseMenuOpenAtom, false)
set(deleteReleaseDialogOpenAtom, false)

View File

@ -7,8 +7,10 @@ import { DeploymentEmptyState, DeploymentStateMessage } from '../../../shared/co
import {
adjustReleaseHistoryPageAfterDeleteAtom,
RELEASE_HISTORY_PAGE_SIZE,
releaseHistoryAtom,
releaseHistoryCurrentPageAtom,
releaseHistoryQueryAtom,
releaseHistoryIsErrorAtom,
releaseHistoryIsLoadingAtom,
setReleaseHistoryCurrentPageAtom,
} from '../state'
import { ReleaseHistoryRows } from './release-history-rows'
@ -20,9 +22,9 @@ export function ReleaseHistoryTable() {
const currentPage = useAtomValue(releaseHistoryCurrentPageAtom)
const setCurrentPage = useSetAtom(setReleaseHistoryCurrentPageAtom)
const adjustPageAfterDelete = useSetAtom(adjustReleaseHistoryPageAfterDeleteAtom)
const releaseHistoryQuery = useAtomValue(releaseHistoryQueryAtom)
const isLoading = releaseHistoryQuery.isLoading
const hasError = releaseHistoryQuery.isError
const releaseHistory = useAtomValue(releaseHistoryAtom)
const isLoading = useAtomValue(releaseHistoryIsLoadingAtom)
const hasError = useAtomValue(releaseHistoryIsErrorAtom)
if (isLoading)
return <ReleaseHistoryTableSkeleton />
@ -35,7 +37,6 @@ export function ReleaseHistoryTable() {
)
}
const releaseHistory = releaseHistoryQuery.data
if (!releaseHistory) {
return (
<DeploymentStateMessage variant="list">

View File

@ -3,6 +3,7 @@
import { keepPreviousData, skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -29,6 +30,10 @@ export const releaseHistoryQueryAtom = atomWithQuery((get) => {
})
})
export const releaseHistoryAtom = selectAtom(releaseHistoryQueryAtom, query => query.data)
export const releaseHistoryIsLoadingAtom = selectAtom(releaseHistoryQueryAtom, query => query.isLoading)
export const releaseHistoryIsErrorAtom = selectAtom(releaseHistoryQueryAtom, query => query.isError)
export const setReleaseHistoryCurrentPageAtom = atom(null, (_get, set, page: number) => {
set(releaseHistoryCurrentPageAtom, Math.max(page, 0))
})

View File

@ -3,6 +3,7 @@
import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom } from 'jotai/utils'
import { nextPathnameAtom } from '@/app/components/next-route-state/atoms'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
@ -35,6 +36,10 @@ export const deploymentDetailAppInstanceQueryAtom = atomWithQuery((get) => {
})
})
export const deploymentDetailAppInstanceAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.data)
export const deploymentDetailAppInstanceIsLoadingAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isLoading)
export const deploymentDetailAppInstanceIsErrorAtom = selectAtom(deploymentDetailAppInstanceQueryAtom, query => query.isError)
export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
@ -49,6 +54,10 @@ export const deploymentEnvironmentDeploymentsQueryAtom = atomWithQuery((get) =>
})
})
export const deploymentEnvironmentDeploymentsAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.data)
export const deploymentEnvironmentDeploymentsIsLoadingAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isLoading)
export const deploymentEnvironmentDeploymentsIsErrorAtom = selectAtom(deploymentEnvironmentDeploymentsQueryAtom, query => query.isError)
export const deploymentRuntimeInstanceRowsAtom = atom((get) => {
return get(deploymentEnvironmentDeploymentsQueryAtom).data?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
return get(deploymentEnvironmentDeploymentsAtom)?.environmentDeployments.filter(hasRuntimeInstanceDeployment) ?? []
})

View File

@ -4,7 +4,7 @@ import type { ReactNode } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithInfiniteQuery, atomWithQuery } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/utils'
import { selectAtom, useHydrateAtoms } from 'jotai/utils'
import { parseAsString, useQueryState } from 'nuqs'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval } from '../../shared/domain/runtime-status'
@ -52,8 +52,10 @@ const deploymentsListEnvironmentsQueryAtom = atomWithQuery(() => {
})
})
const deploymentsListEnvironmentsDataAtom = selectAtom(deploymentsListEnvironmentsQueryAtom, query => query.data)
export const deploymentsListEnvironmentFilterOptionsAtom = atom((get): DeploymentsListEnvironmentFilterOption[] => {
const environments = get(deploymentsListEnvironmentsQueryAtom).data?.environments ?? []
const environments = get(deploymentsListEnvironmentsDataAtom)?.environments ?? []
return [
{
@ -83,7 +85,7 @@ export const deploymentsListSelectedEnvironmentFilterOptionAtom = atom((get): De
: allOption)
})
export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
const queryKeywords = get(deploymentsListKeywordsAtom).trim()
const queryEnvironmentId = get(deploymentsListEnvironmentIdAtom) ?? undefined
@ -114,26 +116,34 @@ export const deploymentsListQueryAtom = atomWithInfiniteQuery((get) => {
})
})
const deploymentsListDataAtom = selectAtom(deploymentsListQueryAtom, query => query.data)
export const deploymentsListErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.error)
export const deploymentsListFetchNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.fetchNextPage)
export const deploymentsListHasNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.hasNextPage)
export const deploymentsListIsFetchingAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetching)
export const deploymentsListIsFetchingNextPageAtom = selectAtom(deploymentsListQueryAtom, query => query.isFetchingNextPage)
export const deploymentsListIsLoadingAtom = selectAtom(deploymentsListQueryAtom, query => query.isLoading)
const deploymentsListIsErrorAtom = selectAtom(deploymentsListQueryAtom, query => query.isError)
export const deploymentsListRowsAtom = atom((get) => {
return get(deploymentsListQueryAtom).data?.pages.flatMap(page => page.appInstanceSummaries) ?? []
return get(deploymentsListDataAtom)?.pages.flatMap(page => page.appInstanceSummaries) ?? []
})
export const deploymentsListShowSkeletonAtom = atom((get) => {
const deploymentsListQuery = get(deploymentsListQueryAtom)
const pages = deploymentsListQuery.data?.pages ?? []
const pages = get(deploymentsListDataAtom)?.pages ?? []
return deploymentsListQuery.isLoading || (deploymentsListQuery.isFetching && pages.length === 0)
return get(deploymentsListIsLoadingAtom) || (get(deploymentsListIsFetchingAtom) && pages.length === 0)
})
export const deploymentsListShowEmptyStateAtom = atom((get) => {
return !get(deploymentsListShowSkeletonAtom)
&& !get(deploymentsListQueryAtom).isError
&& !get(deploymentsListIsErrorAtom)
&& get(deploymentsListRowsAtom).length === 0
})
export const deploymentsListShowErrorStateAtom = atom((get) => {
return !get(deploymentsListShowSkeletonAtom)
&& get(deploymentsListQueryAtom).isError
&& get(deploymentsListIsErrorAtom)
})
export const deploymentsListHasFilterAtom = atom((get) => {

View File

@ -12,8 +12,13 @@ import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../shared/components/empty-state'
import { useInfiniteScroll } from '../../shared/hooks/use-infinite-scroll'
import {
deploymentsListErrorAtom,
deploymentsListFetchNextPageAtom,
deploymentsListHasFilterAtom,
deploymentsListQueryAtom,
deploymentsListHasNextPageAtom,
deploymentsListIsFetchingAtom,
deploymentsListIsFetchingNextPageAtom,
deploymentsListIsLoadingAtom,
deploymentsListRowsAtom,
deploymentsListShowEmptyStateAtom,
deploymentsListShowErrorStateAtom,
@ -157,13 +162,25 @@ function DeploymentsListControls() {
export function DeploymentsListShell() {
const { t } = useTranslation('deployments')
const deploymentsListQuery = useAtomValue(deploymentsListQueryAtom)
const deploymentsListError = useAtomValue(deploymentsListErrorAtom)
const deploymentsListFetchNextPage = useAtomValue(deploymentsListFetchNextPageAtom)
const deploymentsListHasNextPage = useAtomValue(deploymentsListHasNextPageAtom)
const deploymentsListIsFetching = useAtomValue(deploymentsListIsFetchingAtom)
const deploymentsListIsFetchingNextPage = useAtomValue(deploymentsListIsFetchingNextPageAtom)
const deploymentsListIsLoading = useAtomValue(deploymentsListIsLoadingAtom)
const appInstanceSummaries = useAtomValue(deploymentsListRowsAtom)
const showSkeleton = useAtomValue(deploymentsListShowSkeletonAtom)
const showErrorState = useAtomValue(deploymentsListShowErrorStateAtom)
const showEmptyState = useAtomValue(deploymentsListShowEmptyStateAtom)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>(deploymentsListQuery)
const { rootRef, sentinelRef } = useInfiniteScroll<HTMLDivElement>({
error: deploymentsListError,
fetchNextPage: deploymentsListFetchNextPage,
hasNextPage: deploymentsListHasNextPage,
isFetching: deploymentsListIsFetching,
isFetchingNextPage: deploymentsListIsFetchingNextPage,
isLoading: deploymentsListIsLoading,
})
return (
<div ref={rootRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
@ -185,7 +202,7 @@ export function DeploymentsListShell() {
summary={summary}
/>
))}
{deploymentsListQuery.isFetchingNextPage && <DeploymentsListSkeleton />}
{deploymentsListIsFetchingNextPage && <DeploymentsListSkeleton />}
<div ref={sentinelRef} aria-hidden="true" className="col-span-full h-px" />
</div>
</div>