diff --git a/.agents/skills/how-to-write-component/SKILL.md b/.agents/skills/how-to-write-component/SKILL.md index e66eec40c88..9d013876dd9 100644 --- a/.agents/skills/how-to-write-component/SKILL.md +++ b/.agents/skills/how-to-write-component/SKILL.md @@ -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(() => { throw new Error(...) })` when consumers should see a non-null type. diff --git a/web/features/deployments/create-guide/state/queries.ts b/web/features/deployments/create-guide/state/queries.ts index 22541b25b74..b9c099b9efc 100644 --- a/web/features/deployments/create-guide/state/queries.ts +++ b/web/features/deployments/create-guide/state/queries.ts @@ -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) }) diff --git a/web/features/deployments/create-guide/state/source.ts b/web/features/deployments/create-guide/state/source.ts index 39bb834b8f1..274fcd3a401 100644 --- a/web/features/deployments/create-guide/state/source.ts +++ b/web/features/deployments/create-guide/state/source.ts @@ -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) { diff --git a/web/features/deployments/create-guide/state/submission.ts b/web/features/deployments/create-guide/state/submission.ts index 675598e6307..9f13688c0b4 100644 --- a/web/features/deployments/create-guide/state/submission.ts +++ b/web/features/deployments/create-guide/state/submission.ts @@ -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) diff --git a/web/features/deployments/create-guide/state/target.ts b/web/features/deployments/create-guide/state/target.ts index 36b3fee69d9..57f75389f2d 100644 --- a/web/features/deployments/create-guide/state/target.ts +++ b/web/features/deployments/create-guide/state/target.ts @@ -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. 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 46fea37411f..cb620975f8d 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 @@ -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() 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, diff --git a/web/features/deployments/create-guide/ui/source-step.tsx b/web/features/deployments/create-guide/ui/source-step.tsx index 56debc4530a..50c3b938c16 100644 --- a/web/features/deployments/create-guide/ui/source-step.tsx +++ b/web/features/deployments/create-guide/ui/source-step.tsx @@ -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(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({ + 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 && (
{t('createModal.loadingApps')}
)} - {sourceAppsQuery.hasNextPage && diff --git a/web/features/deployments/create-guide/ui/target-step.tsx b/web/features/deployments/create-guide/ui/target-step.tsx index e915aa146b9..f543a5ee969 100644 --- a/web/features/deployments/create-guide/ui/target-step.tsx +++ b/web/features/deployments/create-guide/ui/target-step.tsx @@ -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() { ? : (
- {isEnvironmentError + {environmentsIsError ? t('createGuide.target.loadEnvironmentsFailed') : t('createGuide.target.noEnvironmentOptions')}
@@ -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 diff --git a/web/features/deployments/create-release/state/index.ts b/web/features/deployments/create-release/state/index.ts index b47ea3243be..67a670b351a 100644 --- a/web/features/deployments/create-release/state/index.ts +++ b/web/features/deployments/create-release/state/index.ts @@ -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 ?? '' }) 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 a33f2664042..d7bbc829df1 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 @@ -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', 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 414d12a60f6..e495a8b3ebf 100644 --- a/web/features/deployments/create-release/ui/source-app-picker.tsx +++ b/web/features/deployments/create-release/ui/source-app-picker.tsx @@ -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(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({ + 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 ( items={apps} @@ -208,23 +219,23 @@ export function SourceAppPicker({ value, onChange, disabled = false }: {
- {(isLoading || isFetchingNextPage) && apps.length === 0 && } + {(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && apps.length === 0 && } {(app: App) => ( )} - {!(isLoading || isFetchingNextPage) && ( + {!(sourceAppsIsLoading || sourceAppsIsFetchingNextPage) && ( {t('createModal.appSearchEmpty')} )} - {isFetchingNextPage && apps.length > 0 && ( + {sourceAppsIsFetchingNextPage && apps.length > 0 && (
{t('createModal.loadingApps')}
)} - {hasNextPage && diff --git a/web/features/deployments/deploy-drawer/state/index.ts b/web/features/deployments/deploy-drawer/state/index.ts index f0d37466567..9e5bbb50263 100644 --- a/web/features/deployments/deploy-drawer/state/index.ts +++ b/web/features/deployments/deploy-drawer/state/index.ts @@ -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(undefined) const selectedReleaseIdAtom = atom(undefined) const manualBindingsAtom = atom({}) @@ -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), ) diff --git a/web/features/deployments/deploy-drawer/ui/form.tsx b/web/features/deployments/deploy-drawer/ui/form.tsx index 6940c122380..8d11d9584b9 100644 --- a/web/features/deployments/deploy-drawer/ui/form.tsx +++ b/web/features/deployments/deploy-drawer/ui/form.tsx @@ -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 } - if (releaseDeploymentViewQuery.isError) { + if (isError) { return (
{t('common.loadFailed')} @@ -217,7 +219,6 @@ function DeployFormContent({ ) } - const deploymentView = releaseDeploymentViewQuery.data if (!deploymentView) { return (
diff --git a/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx b/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx index ab08477cc94..d5c7c45eb17 100644 --- a/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx +++ b/web/features/deployments/detail/access/channels/__tests__/section.spec.tsx @@ -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 }) }) diff --git a/web/features/deployments/detail/access/channels/section.tsx b/web/features/deployments/detail/access/channels/section.tsx index affd031b790..e772a1ca8e0 100644 --- a/web/features/deployments/detail/access/channels/section.tsx +++ b/web/features/deployments/detail/access/channels/section.tsx @@ -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 diff --git a/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx b/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx index ecdbc625769..577be5ce086 100644 --- a/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx +++ b/web/features/deployments/detail/access/permissions/__tests__/permissions.spec.tsx @@ -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 }) }) diff --git a/web/features/deployments/detail/access/permissions/section.tsx b/web/features/deployments/detail/access/permissions/section.tsx index c1153c36bc6..8ff5e5d2d99 100644 --- a/web/features/deployments/detail/access/permissions/section.tsx +++ b/web/features/deployments/detail/access/permissions/section.tsx @@ -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 ( diff --git a/web/features/deployments/detail/access/state.ts b/web/features/deployments/detail/access/state.ts index 620678f9496..fea3e11ccf7 100644 --- a/web/features/deployments/detail/access/state.ts +++ b/web/features/deployments/detail/access/state.ts @@ -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) diff --git a/web/features/deployments/detail/api-tokens/api-token-management/section.tsx b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx index 34eb480a9de..5938d2822e9 100644 --- a/web/features/deployments/detail/api-tokens/api-token-management/section.tsx +++ b/web/features/deployments/detail/api-tokens/api-token-management/section.tsx @@ -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() - 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 - if (developerApiSettingsQuery.isError || !appInstanceId) + if (isError || !appInstanceId) return {t('common.loadFailed')} if (!apiEnabled) { diff --git a/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx index 9415754ab5e..348d2d34d79 100644 --- a/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx +++ b/web/features/deployments/detail/api-tokens/developer-api-header-switch.tsx @@ -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 return ( @@ -58,7 +64,7 @@ export function DeveloperApiHeaderSwitch() {
) diff --git a/web/features/deployments/detail/api-tokens/state.ts b/web/features/deployments/detail/api-tokens/state.ts index 9eece7967e2..7fde83b0d13 100644 --- a/web/features/deployments/detail/api-tokens/state.ts +++ b/web/features/deployments/detail/api-tokens/state.ts @@ -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) diff --git a/web/features/deployments/detail/deployment-sidebar.tsx b/web/features/deployments/detail/deployment-sidebar.tsx index 95f50c768d6..74a98d606b2 100644 --- a/web/features/deployments/detail/deployment-sidebar.tsx +++ b/web/features/deployments/detail/deployment-sidebar.tsx @@ -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 ( diff --git a/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx index 30531fde96b..d35d5afffc7 100644 --- a/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx +++ b/web/features/deployments/detail/instances/header-actions/new-deployment-button.tsx @@ -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 diff --git a/web/features/deployments/detail/instances/index.tsx b/web/features/deployments/detail/instances/index.tsx index e33ce938d43..8df9101f916 100644 --- a/web/features/deployments/detail/instances/index.tsx +++ b/web/features/deployments/detail/instances/index.tsx @@ -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 (
diff --git a/web/features/deployments/detail/overview/index.tsx b/web/features/deployments/detail/overview/index.tsx index da486fe10ca..fed571c6543 100644 --- a/web/features/deployments/detail/overview/index.tsx +++ b/web/features/deployments/detail/overview/index.tsx @@ -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 - if (overviewQuery.isError) { + if (isError) { return ( {t('common.loadFailed')} diff --git a/web/features/deployments/detail/overview/state.ts b/web/features/deployments/detail/overview/state.ts index c9b1c9b7bc9..ee7cb68c3ce 100644 --- a/web/features/deployments/detail/overview/state.ts +++ b/web/features/deployments/detail/overview/state.ts @@ -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) diff --git a/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx index aa22da2ed76..a0f0ff76305 100644 --- a/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx +++ b/web/features/deployments/detail/releases/release-actions/__tests__/deploy-release-menu.spec.tsx @@ -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() diff --git a/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx index ce97ff261b8..a0a125ab2b4 100644 --- a/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx +++ b/web/features/deployments/detail/releases/release-actions/deploy-release-menu.tsx @@ -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, diff --git a/web/features/deployments/detail/releases/release-actions/state.ts b/web/features/deployments/detail/releases/release-actions/state.ts index de9630800f9..a4e164ccedf 100644 --- a/web/features/deployments/detail/releases/release-actions/state.ts +++ b/web/features/deployments/detail/releases/release-actions/state.ts @@ -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) diff --git a/web/features/deployments/detail/releases/release-history/release-history-table.tsx b/web/features/deployments/detail/releases/release-history/release-history-table.tsx index 07debb96df1..c154c2bf1d2 100644 --- a/web/features/deployments/detail/releases/release-history/release-history-table.tsx +++ b/web/features/deployments/detail/releases/release-history/release-history-table.tsx @@ -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 @@ -35,7 +37,6 @@ export function ReleaseHistoryTable() { ) } - const releaseHistory = releaseHistoryQuery.data if (!releaseHistory) { return ( diff --git a/web/features/deployments/detail/releases/state.ts b/web/features/deployments/detail/releases/state.ts index add79f6e2a0..43500b45ea5 100644 --- a/web/features/deployments/detail/releases/state.ts +++ b/web/features/deployments/detail/releases/state.ts @@ -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)) }) diff --git a/web/features/deployments/detail/state.ts b/web/features/deployments/detail/state.ts index 6356964f797..cc521f15804 100644 --- a/web/features/deployments/detail/state.ts +++ b/web/features/deployments/detail/state.ts @@ -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) ?? [] }) diff --git a/web/features/deployments/list/state/index.ts b/web/features/deployments/list/state/index.ts index f33a2525640..db52a0f329b 100644 --- a/web/features/deployments/list/state/index.ts +++ b/web/features/deployments/list/state/index.ts @@ -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) => { diff --git a/web/features/deployments/list/ui/shell.tsx b/web/features/deployments/list/ui/shell.tsx index e94c870eaa5..97313e60039 100644 --- a/web/features/deployments/list/ui/shell.tsx +++ b/web/features/deployments/list/ui/shell.tsx @@ -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(deploymentsListQuery) + const { rootRef, sentinelRef } = useInfiniteScroll({ + error: deploymentsListError, + fetchNextPage: deploymentsListFetchNextPage, + hasNextPage: deploymentsListHasNextPage, + isFetching: deploymentsListIsFetching, + isFetchingNextPage: deploymentsListIsFetchingNextPage, + isLoading: deploymentsListIsLoading, + }) return (
@@ -185,7 +202,7 @@ export function DeploymentsListShell() { summary={summary} /> ))} - {deploymentsListQuery.isFetchingNextPage && } + {deploymentsListIsFetchingNextPage && }