diff --git a/packages/sanity/src/core/releases/contexts/upsell/ReleasesUpsellProvider.tsx b/packages/sanity/src/core/releases/contexts/upsell/ReleasesUpsellProvider.tsx index 51569d3b04f..6a3ae5c3310 100644 --- a/packages/sanity/src/core/releases/contexts/upsell/ReleasesUpsellProvider.tsx +++ b/packages/sanity/src/core/releases/contexts/upsell/ReleasesUpsellProvider.tsx @@ -1,6 +1,8 @@ +/* eslint-disable no-console */ import {useTelemetry} from '@sanity/telemetry/react' import {template} from 'lodash' import {useCallback, useEffect, useMemo, useState} from 'react' +import {firstValueFrom} from 'rxjs' import {ReleasesUpsellContext} from 'sanity/_singletons' import {useClient, useFeatureEnabled, useProjectId} from '../../../hooks' @@ -14,6 +16,8 @@ import {TEMPLATE_OPTIONS} from '../../../studio/upsell/constants' import {type UpsellData} from '../../../studio/upsell/types' import {UpsellDialog} from '../../../studio/upsell/UpsellDialog' import {useActiveReleases} from '../../store/useActiveReleases' +import {useOrgActiveReleaseCount} from '../../store/useOrgActiveReleaseCount' +import {useReleaseLimits} from '../../store/useReleaseLimits' import {type ReleasesUpsellContextValue} from './types' class StudioReleaseLimitExceededError extends Error { @@ -43,7 +47,6 @@ export function ReleasesUpsellProvider(props: {children: React.ReactNode}) { const projectId = useProjectId() const telemetry = useTelemetry() const client = useClient({apiVersion: API_VERSION}) - const [releaseLimit, setReleaseLimit] = useState(undefined) const {data: activeReleases} = useActiveReleases() const {enabled: isReleasesFeatureEnabled} = useFeatureEnabled('contentReleases') @@ -51,18 +54,15 @@ export function ReleasesUpsellProvider(props: {children: React.ReactNode}) { /** * upsell if: * plan is free, ie releases is not feature enabled - * there is a limit and the limit is reached or exceeded */ - const isAtReleaseLimit = - !isReleasesFeatureEnabled || (releaseLimit && (activeReleases?.length || 0) >= releaseLimit) - if (isAtReleaseLimit && upsellData) { + if (!isReleasesFeatureEnabled && upsellData) { return 'upsell' } - if (isAtReleaseLimit && !upsellData) { + if (isReleasesFeatureEnabled && !upsellData) { return 'disabled' } return 'default' - }, [activeReleases?.length, isReleasesFeatureEnabled, releaseLimit, upsellData]) + }, [isReleasesFeatureEnabled, upsellData]) const telemetryLogs = useMemo( (): ReleasesUpsellContextValue['telemetryLogs'] => ({ @@ -146,40 +146,93 @@ export function ReleasesUpsellProvider(props: {children: React.ReactNode}) { } }, [client, projectId]) - const handleOpenDialog = useCallback(() => { - setUpsellDialogOpen(true) + const [releaseCount, setReleaseCount] = useState(null) - telemetry.log(UpsellDialogViewed, { - feature: FEATURE, - type: 'modal', - source: 'navbar', - }) - }, [telemetry]) + const handleOpenDialog = useCallback( + (orgActiveReleaseCount?: number) => { + setUpsellDialogOpen(true) + if (orgActiveReleaseCount !== undefined) { + setReleaseCount(orgActiveReleaseCount) + } + + telemetry.log(UpsellDialogViewed, { + feature: FEATURE, + type: 'modal', + source: 'navbar', + }) + }, + [telemetry], + ) + + const limitsClient = client.withConfig({apiVersion: 'vX'}).observable + const {releaseLimits$} = useReleaseLimits(limitsClient) + const {orgActiveReleaseCount$} = useOrgActiveReleaseCount(limitsClient) const guardWithReleaseLimitUpsell = useCallback( - (cb: () => void, throwError: boolean = false) => { - if (mode === 'default') { - return cb() + async (cb: () => void, throwError: boolean = false) => { + const doUpsell: (count?: number) => false = (count) => { + handleOpenDialog(count) + if (throwError) { + throw new StudioReleaseLimitExceededError() + } + return false } - handleOpenDialog() - if (throwError) { - throw new StudioReleaseLimitExceededError() + if (mode === 'upsell') return doUpsell() + + const fetchLimitsCount = async () => { + try { + console.log('Guard called, checking caches...') + // if either fails then catch the error + return await Promise.all([ + firstValueFrom(orgActiveReleaseCount$), + firstValueFrom(releaseLimits$), + ]) + } catch (e) { + console.error('Error fetching release limits and org count for upsell:', e) + + return null + } } - return false + + const result = await fetchLimitsCount() + + // silently fail and allow pass through guard + if (result === null) return cb() + + const [orgActiveReleaseCount, releaseLimits] = result + + if (releaseLimits === null || orgActiveReleaseCount === null) return cb() + + const {orgActiveReleaseLimit, datasetReleaseLimit} = releaseLimits + + // orgActiveReleaseCount might be missing due to internal server error + // allow pass through guard in that case + if (orgActiveReleaseCount === null) return cb() + + const activeReleasesCount = activeReleases?.length || 0 + + const isCurrentDatasetAtAboveDatasetLimit = activeReleasesCount >= datasetReleaseLimit + const isCurrentDatasetAtAboveOrgLimit = + orgActiveReleaseLimit !== null && activeReleasesCount >= orgActiveReleaseLimit + const isOrgAtAboveOrgLimit = + orgActiveReleaseLimit !== null && orgActiveReleaseCount >= orgActiveReleaseLimit + + const shouldShowDialog = + isCurrentDatasetAtAboveDatasetLimit || + isCurrentDatasetAtAboveOrgLimit || + isOrgAtAboveOrgLimit + + if (shouldShowDialog) return doUpsell(orgActiveReleaseCount) + + return cb() }, - [handleOpenDialog, mode], + [mode, handleOpenDialog, orgActiveReleaseCount$, releaseLimits$, activeReleases?.length], ) const onReleaseLimitReached = useCallback( - (limit: number, suppressDialogOpening: boolean = false) => { - setReleaseLimit(limit) - - if (!suppressDialogOpening && (activeReleases?.length || 0) >= limit) { - handleOpenDialog() - } - }, - [activeReleases?.length, handleOpenDialog], + (limit: number) => handleOpenDialog(limit), + [handleOpenDialog], ) const ctxValue = useMemo( @@ -193,7 +246,7 @@ export function ReleasesUpsellProvider(props: {children: React.ReactNode}) { [mode, upsellDialogOpen, guardWithReleaseLimitUpsell, onReleaseLimitReached, telemetryLogs], ) - const interpolation = releaseLimit ? {releaseLimit} : undefined + const interpolation = releaseCount === null ? undefined : {releaseLimit: releaseCount} return ( diff --git a/packages/sanity/src/core/releases/contexts/upsell/fetchReleaseLimits.ts b/packages/sanity/src/core/releases/contexts/upsell/fetchReleaseLimits.ts new file mode 100644 index 00000000000..32dd5a9ace5 --- /dev/null +++ b/packages/sanity/src/core/releases/contexts/upsell/fetchReleaseLimits.ts @@ -0,0 +1,53 @@ +import {type ClientError, type ObservableSanityClient} from '@sanity/client' +import {catchError, map, type Observable, of, share} from 'rxjs' + +export interface ReleaseLimits { + orgActiveReleaseCount: number + defaultOrgActiveReleaseLimit: number + datasetReleaseLimit: number + // internal server error has no fallback number - it uses null + orgActiveReleaseLimit: number | null +} + +interface ReleaseLimitsResponse { + data: ReleaseLimits +} + +/** + * @internal + * fetches subscriptions for this project + */ +export function fetchReleaseLimits(clientOb: ObservableSanityClient): Observable { + const {projectId} = clientOb.config() + + // This endpoint is prone to optimisation and further work + // it will never live within a versions API and will always be on vX + // until it goes away - there is graceful handling in `catchError` + // for when this endpoint is no longer available and limits are fetched + // some other way + + return clientOb + .withConfig({apiVersion: 'vX'}) + .request({ + uri: `projects/${projectId}/new-content-release-allowed`, + // tag: `new-${new Date().getTime()}`, + tag: 'new-content-release-allowed', + }) + .pipe( + share(), + catchError((error: ClientError) => { + console.error(error) + + if (typeof error.response.body !== 'string' && 'data' in error.response.body) { + // body will still contain the limits and current count (if available) + // so still want to return these and just silently log the error + return of(error.response.body as ReleaseLimitsResponse) + } + + // for internal server errors, or as a fallback + // propagate up the error + throw error + }), + map(({data}) => data), + ) +} diff --git a/packages/sanity/src/core/releases/contexts/upsell/types.ts b/packages/sanity/src/core/releases/contexts/upsell/types.ts index c8938335f8d..67019c55830 100644 --- a/packages/sanity/src/core/releases/contexts/upsell/types.ts +++ b/packages/sanity/src/core/releases/contexts/upsell/types.ts @@ -8,8 +8,8 @@ export interface ReleasesUpsellContextValue { */ mode: 'upsell' | 'default' | 'disabled' upsellDialogOpen: boolean - guardWithReleaseLimitUpsell: (callback: () => void, throwError?: boolean) => false | void - onReleaseLimitReached: (limit: number, suppressDialogOpening: boolean) => void + guardWithReleaseLimitUpsell: (callback: () => void, throwError?: boolean) => Promise + onReleaseLimitReached: (limit: number) => void telemetryLogs: { dialogSecondaryClicked: () => void dialogPrimaryClicked: () => void diff --git a/packages/sanity/src/core/releases/contexts/upsell/useReleasesUpsell.ts b/packages/sanity/src/core/releases/contexts/upsell/useReleasesUpsell.ts index e8b065e568f..6236103cfd2 100644 --- a/packages/sanity/src/core/releases/contexts/upsell/useReleasesUpsell.ts +++ b/packages/sanity/src/core/releases/contexts/upsell/useReleasesUpsell.ts @@ -19,7 +19,7 @@ const FALLBACK_CONTEXT_VALUE = { upsellDialogOpen: false, mode: 'default' as const, onReleaseLimitReached: () => null, - guardWithReleaseLimitUpsell: () => undefined, + guardWithReleaseLimitUpsell: async () => undefined, telemetryLogs: { dialogSecondaryClicked: () => null, dialogPrimaryClicked: () => null, diff --git a/packages/sanity/src/core/releases/hooks/useIsReleasesPlus.ts b/packages/sanity/src/core/releases/hooks/useIsReleasesPlus.ts new file mode 100644 index 00000000000..57f4be99262 --- /dev/null +++ b/packages/sanity/src/core/releases/hooks/useIsReleasesPlus.ts @@ -0,0 +1,21 @@ +import {useObservable} from 'react-rx' + +import {useClient} from '../../hooks/useClient' +import {useReleaseLimits} from '../store/useReleaseLimits' + +/** + * @internal + * @returns `boolean` Whether the current org is on a Releases+ plan + */ +export const useIsReleasesPlus = (): boolean => { + const client = useClient().observable + const {releaseLimits$} = useReleaseLimits(client) + + const releaseLimit = useObservable(releaseLimits$, null) + + const {orgActiveReleaseLimit, defaultOrgActiveReleaseLimit = 0} = releaseLimit || {} + + // presume not releases+ if null releaseLimit + // (because of internal server error or network error) + return !!orgActiveReleaseLimit && orgActiveReleaseLimit >= defaultOrgActiveReleaseLimit +} diff --git a/packages/sanity/src/core/releases/store/__tests__/createReleasePermissionsStore.test.ts b/packages/sanity/src/core/releases/store/__tests__/createReleasePermissionsStore.test.ts index 49f5475474f..9503ca156d9 100644 --- a/packages/sanity/src/core/releases/store/__tests__/createReleasePermissionsStore.test.ts +++ b/packages/sanity/src/core/releases/store/__tests__/createReleasePermissionsStore.test.ts @@ -1,25 +1,28 @@ -import {beforeEach, describe, expect, it, vi} from 'vitest' +import {describe, expect, it, vi} from 'vitest' import {createReleasePermissionsStore} from '../createReleasePermissionsStore' -import {type useReleasePermissionsValue} from '../useReleasePermissions' - -const createStore = () => createReleasePermissionsStore() describe('useReleasePermissions', () => { - let store: useReleasePermissionsValue + describe('when content release feature is enabled', () => { + it('should return true when action succeeds', async () => { + const mockAction = vi.fn().mockResolvedValueOnce(undefined) + const result = await createReleasePermissionsStore(true).checkWithPermissionGuard(mockAction) - beforeEach(() => { - store = createStore() + expect(result).toBe(true) + expect(mockAction).toHaveBeenCalledWith({ + dryRun: true, + skipCrossDatasetReferenceValidation: true, + }) + }) }) - it('should return true when action succeeds', async () => { - const mockAction = vi.fn().mockResolvedValueOnce(undefined) - const result = await store.checkWithPermissionGuard(mockAction) + describe('when content release feature is disabled', () => { + it('should allow permissions', async () => { + const mockAction = vi.fn() + const result = await createReleasePermissionsStore(false).checkWithPermissionGuard(mockAction) - expect(result).toBe(true) - expect(mockAction).toHaveBeenCalledWith({ - dryRun: true, - skipCrossDatasetReferenceValidation: true, + expect(result).toBe(true) + expect(mockAction).not.toHaveBeenCalled() }) }) }) diff --git a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts index 0d98df737f5..9814d17793e 100644 --- a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts +++ b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts @@ -355,9 +355,11 @@ export function createRequestAction( }, }) } catch (e) { - if (isReleaseLimitError(e)) { + // if dryRunning then essentially this is a silent request + // so don't want to create disruptive upsell because of limit + if (!options?.dryRun && isReleaseLimitError(e)) { // free accounts do not return limit, 0 is implied - onReleaseLimitReached(e.details.limit || 0, !!options?.dryRun) + onReleaseLimitReached(e.details.limit || 0) } throw e diff --git a/packages/sanity/src/core/releases/store/createReleasePermissionsStore.ts b/packages/sanity/src/core/releases/store/createReleasePermissionsStore.ts index 722e4b85cc9..0403ea5793e 100644 --- a/packages/sanity/src/core/releases/store/createReleasePermissionsStore.ts +++ b/packages/sanity/src/core/releases/store/createReleasePermissionsStore.ts @@ -22,7 +22,9 @@ export const isReleasePermissionError = (error: unknown): error is ReleasePermis * * @internal */ -export function createReleasePermissionsStore(): useReleasePermissionsValue { +export function createReleasePermissionsStore( + isContentReleasesEnabled: boolean, +): useReleasePermissionsValue { let permissions: {[key: string]: boolean} = {} /** @@ -36,6 +38,14 @@ export function createReleasePermissionsStore(): useReleasePermissionsValue { action: T, ...args: Parameters ): Promise => { + if (!isContentReleasesEnabled) { + /** + * When content releases feature flag is disabled + * assume allowed permissions to provide upsell + */ + return true + } + if (permissions[action.name] === undefined) { try { await action(...args, { diff --git a/packages/sanity/src/core/releases/store/useOrgActiveReleaseCount.ts b/packages/sanity/src/core/releases/store/useOrgActiveReleaseCount.ts new file mode 100644 index 00000000000..488477b17ca --- /dev/null +++ b/packages/sanity/src/core/releases/store/useOrgActiveReleaseCount.ts @@ -0,0 +1,105 @@ +import {type ObservableSanityClient} from '@sanity/client' +import {useMemo} from 'react' +import {BehaviorSubject, catchError, map, type Observable, of, switchMap, tap, timer} from 'rxjs' + +import {useResourceCache} from '../../store/_legacy/ResourceCacheProvider' +import {type ReleaseLimits} from '../contexts/upsell/fetchReleaseLimits' +import {useActiveReleases} from './useActiveReleases' +import {type ReleaseLimitsStore, useReleaseLimits} from './useReleaseLimits' + +interface OrgActiveReleaseCountStore { + orgActiveReleaseCount$: Observable +} + +// @todo make this 60_000 +const STATE_TTL_MS = 15_000 +const ORG_ACTIVE_RELEASE_COUNT_RESOURCE_CACHE_NAMESPACE = 'orgActiveReleaseCount' + +function createOrgActiveReleaseCountStore( + releaseLimits$: ReleaseLimitsStore['releaseLimits$'], + activeReleasesCount: number, +): OrgActiveReleaseCountStore { + const latestFetchState = new BehaviorSubject(null) + const staleFlag$ = new BehaviorSubject(false) + const activeReleaseCountAtFetch = new BehaviorSubject(null) + + const orgActiveReleaseCount$ = latestFetchState.pipe( + switchMap((state) => { + if ( + state === null || + staleFlag$.getValue() === true || + activeReleaseCountAtFetch.getValue() !== activeReleasesCount + ) { + staleFlag$.next(false) + + return releaseLimits$.pipe( + tap(() => activeReleaseCountAtFetch.next(activeReleasesCount)), + map((data) => data?.orgActiveReleaseCount), + catchError((error) => { + console.error('Failed to fetch org release count', error) + + if (!state) throw error + + // return the last state if it exists + return of(state) + }), + switchMap((nextState) => { + if (typeof nextState === 'number') { + latestFetchState.next(nextState) + } + + timer(STATE_TTL_MS).subscribe(() => { + staleFlag$.next(true) + activeReleaseCountAtFetch.next(null) + }) + + return of(nextState ?? 0) + }), + ) + } + + return of(state) + }), + ) + + return { + orgActiveReleaseCount$, + } +} + +/** + * @internal + * + * Returns a shared observable to a cache of the org's active release count. + * + * This cache expires after a TTL or whenever the active releases in the current + * dataset changes. + * + * @returns An Observable of the cached value for org's active release count. + */ +export const useOrgActiveReleaseCount = (clientOb: ObservableSanityClient) => { + const resourceCache = useResourceCache() + const {data: activeReleases} = useActiveReleases() + // const client = useClient() + const {releaseLimits$} = useReleaseLimits(clientOb) + + const activeReleasesCount = activeReleases?.length || 0 + + const count = useMemo(() => ({activeReleasesCount}), [activeReleasesCount]) + + return useMemo(() => { + const releaseLimitsStore = + resourceCache.get({ + dependencies: [clientOb, count], + namespace: ORG_ACTIVE_RELEASE_COUNT_RESOURCE_CACHE_NAMESPACE, + }) || createOrgActiveReleaseCountStore(releaseLimits$, activeReleasesCount) + + resourceCache.set({ + namespace: ORG_ACTIVE_RELEASE_COUNT_RESOURCE_CACHE_NAMESPACE, + value: releaseLimitsStore, + dependencies: [clientOb, count], + }) + + return releaseLimitsStore + }, [activeReleasesCount, clientOb, count, releaseLimits$, resourceCache]) +} diff --git a/packages/sanity/src/core/releases/store/useReleaseLimits.ts b/packages/sanity/src/core/releases/store/useReleaseLimits.ts new file mode 100644 index 00000000000..5e5cb7c16f5 --- /dev/null +++ b/packages/sanity/src/core/releases/store/useReleaseLimits.ts @@ -0,0 +1,64 @@ +import {type SanityClient} from '@sanity/client' +import {useMemo} from 'react' +import {catchError, map, type Observable, of, shareReplay} from 'rxjs' + +import {useResourceCache} from '../../store/_legacy/ResourceCacheProvider' +import {fetchReleaseLimits, type ReleaseLimits} from '../contexts/upsell/fetchReleaseLimits' + +export interface ReleaseLimitsStore { + releaseLimits$: Observable +} + +const RELEASE_LIMITS_RESOURCE_CACHE_NAMESPACE = 'ReleaseLimits' + +function createReleaseLimitsStore(client: SanityClient): ReleaseLimitsStore { + const releaseLimits$ = fetchReleaseLimits(client).pipe( + map((data) => ({ + defaultOrgActiveReleaseLimit: data.defaultOrgActiveReleaseLimit, + datasetReleaseLimit: data.datasetReleaseLimit, + orgActiveReleaseLimit: data.orgActiveReleaseLimit, + orgActiveReleaseCount: data.orgActiveReleaseCount, + })), + shareReplay(1), + catchError((error) => { + console.error('Failed to fetch release limits', error) + + return of(null) + }), + ) + + return { + releaseLimits$, + } +} + +/** + * @internal + * + * Returns a shared observable to a cache of the release limits for the current project. + * + * This cache is shared across all instances of this hook, and will only be fetched once. + * It will never expire as the limits are not expected to change during the lifetime of the render cycle. + * + * @returns An Observable of the cached value for the release limits + */ +export const useReleaseLimits: (clientOb: any) => ReleaseLimitsStore = (clientOb) => { + const resourceCache = useResourceCache() + // const client = useClient() + + return useMemo(() => { + const releaseLimitsStore = + resourceCache.get({ + dependencies: [clientOb], + namespace: RELEASE_LIMITS_RESOURCE_CACHE_NAMESPACE, + }) || createReleaseLimitsStore(clientOb) + + resourceCache.set({ + namespace: RELEASE_LIMITS_RESOURCE_CACHE_NAMESPACE, + value: releaseLimitsStore, + dependencies: [clientOb], + }) + + return releaseLimitsStore + }, [clientOb, resourceCache]) +} diff --git a/packages/sanity/src/core/releases/store/useReleasePermissions.ts b/packages/sanity/src/core/releases/store/useReleasePermissions.ts index 9c1f66aebde..51eab5bc08a 100644 --- a/packages/sanity/src/core/releases/store/useReleasePermissions.ts +++ b/packages/sanity/src/core/releases/store/useReleasePermissions.ts @@ -1,5 +1,6 @@ import {useMemo} from 'react' +import {useFeatureEnabled} from '../../hooks/useFeatureEnabled' import {useResourceCache} from '../../store/_legacy/ResourceCacheProvider' import {createReleasePermissionsStore} from './createReleasePermissionsStore' @@ -18,20 +19,21 @@ export interface useReleasePermissionsValue { */ export function useReleasePermissions(): useReleasePermissionsValue { const resourceCache = useResourceCache() + const contentReleasesFeature = useFeatureEnabled('contentReleases') return useMemo(() => { const releasePermissionsStore = resourceCache.get({ - dependencies: [], + dependencies: [contentReleasesFeature], namespace: RELEASE_PERMISSIONS_RESOURCE_CACHE_NAMESPACE, - }) || createReleasePermissionsStore() + }) || createReleasePermissionsStore(contentReleasesFeature.enabled) resourceCache.set({ namespace: RELEASE_PERMISSIONS_RESOURCE_CACHE_NAMESPACE, value: releasePermissionsStore, - dependencies: [], + dependencies: [contentReleasesFeature], }) return releasePermissionsStore - }, [resourceCache]) + }, [contentReleasesFeature, resourceCache]) } diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx index d37a71aaa5e..8bb9dd22fd7 100644 --- a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx @@ -9,6 +9,7 @@ import {Dialog} from '../../../../../../ui-components/dialog' import {Translate, useTranslation} from '../../../../../i18n' import {RevertRelease} from '../../../../__telemetry__/releases.telemetry' import {useReleasesUpsell} from '../../../../contexts/upsell/useReleasesUpsell' +import {useIsReleasesPlus} from '../../../../hooks/useIsReleasesPlus' import {releasesLocaleNamespace} from '../../../../i18n' import {isReleaseLimitError} from '../../../../store/isReleaseLimitError' import {type ReleaseDocument} from '../../../../store/types' @@ -212,9 +213,6 @@ const ConfirmReleaseDialog = ({ ) } -// TODO: This is going to be disabled until we have the proper "releases plus" flag -const isRevertEnabled = false - export const ReleaseRevertButton = ({ release, documents, @@ -223,13 +221,17 @@ export const ReleaseRevertButton = ({ const {t} = useTranslation(releasesLocaleNamespace) const {guardWithReleaseLimitUpsell, mode} = useReleasesUpsell() const [revertReleaseStatus, setRevertReleaseStatus] = useState('idle') + const [isPendingGuardResponse, setIsPendingGuardResponse] = useState(false) - const handleMoveToConfirmStatus = useCallback( - () => guardWithReleaseLimitUpsell(() => setRevertReleaseStatus('confirm')), - [guardWithReleaseLimitUpsell], - ) + const handleMoveToConfirmStatus = useCallback(async () => { + setIsPendingGuardResponse(true) + await guardWithReleaseLimitUpsell(() => setRevertReleaseStatus('confirm')) + setIsPendingGuardResponse(false) + }, [guardWithReleaseLimitUpsell]) + + const isReleasesPlus = useIsReleasesPlus() - if (!isRevertEnabled) return null + if (!isReleasesPlus) return null return ( <> @@ -238,7 +240,7 @@ export const ReleaseRevertButton = ({ onClick={handleMoveToConfirmStatus} text={t('action.revert')} tone="critical" - disabled={disabled || mode === 'disabled'} + disabled={isPendingGuardResponse || disabled || mode === 'disabled'} /> {revertReleaseStatus !== 'idle' && ( (null) + const [isPendingGuardResponse, setIsPendingGuardResponse] = useState(false) const mediaIndex = useMediaIndex() @@ -241,16 +242,24 @@ export function ReleasesOverview() { archivedReleases.length, ]) - const handleOnClickCreateRelease = useCallback( - () => guardWithReleaseLimitUpsell(() => setIsCreateReleaseDialogOpen(true)), - [guardWithReleaseLimitUpsell], - ) + const handleOnClickCreateRelease = useCallback(async () => { + setIsPendingGuardResponse(true) + await guardWithReleaseLimitUpsell(() => { + setIsCreateReleaseDialogOpen(true) + }) + setIsPendingGuardResponse(false) + }, [guardWithReleaseLimitUpsell]) const createReleaseButton = useMemo( () => ( ), - [hasCreatePermission, isCreateReleaseDialogOpen, mode, handleOnClickCreateRelease, tCore], + [ + isPendingGuardResponse, + hasCreatePermission, + isCreateReleaseDialogOpen, + mode, + handleOnClickCreateRelease, + tCore, + ], ) const handleOnCreateRelease = useCallback(