diff --git a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx index dda6f2a690..340cbf3ce7 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/add-seats.tsx @@ -20,6 +20,7 @@ import PoNumber from '@/features/group-management/components/add-seats/po-number import CostSummary from '@/features/group-management/components/add-seats/cost-summary' import RequestStatus from '@/features/group-management/components/request-status' import useAsync from '@/shared/hooks/use-async' +import useAsyncWithCancel from '@/shared/hooks/use-async-with-cancel' import getMeta from '@/utils/meta' import { FetchError, postJSON } from '@/infrastructure/fetch-json' import { debugConsole } from '@/utils/debugging' @@ -50,7 +51,6 @@ function AddSeats() { const [addSeatsInputError, setAddSeatsInputError] = useState() const [poNumberInputError, setPoNumberInputError] = useState() const [shouldContactSales, setShouldContactSales] = useState(false) - const controller = useAbortController() const { signal: addSeatsSignal } = useAbortController() const { signal: contactSalesSignal } = useAbortController() const { @@ -60,7 +60,8 @@ function AddSeats() { data: costSummaryData, reset: resetCostSummaryData, error: errorCostSummary, - } = useAsync() + cancelAll: cancelCostSummaryRequest, + } = useAsyncWithCancel() const [isAddingSeats, setIsAddingSeats] = useState(false) const [isErrorAddingSeats, setIsErrorAddingSeats] = useState(false) const [isSuccessAddingSeats, setIsSuccessAddingSeats] = useState(false) @@ -85,14 +86,21 @@ function AddSeats() { const debouncedCostSummaryRequest = useMemo( () => - debounce((value: number, signal: AbortSignal) => { - const post = postJSON('/user/subscription/group/add-users/preview', { - signal, - body: { adding: value }, + debounce((value: number) => { + cancelCostSummaryRequest() + const post = (signal: AbortSignal) => + postJSON('/user/subscription/group/add-users/preview', { + body: { adding: value }, + signal, + }) + + runAsyncCostSummary(post).catch(error => { + if (error.name !== 'AbortError') { + debugConsole.error(error) + } }) - runAsyncCostSummary(post).catch(debugConsole.error) }, 500), - [runAsyncCostSummary] + [runAsyncCostSummary, cancelCostSummaryRequest] ) const debouncedTrackUserEnterSeatNumberEvent = useMemo( @@ -168,14 +176,15 @@ function AddSeats() { debouncedCostSummaryRequest.cancel() shouldContactSales = true } else { - debouncedCostSummaryRequest(seats, controller.signal) + debouncedCostSummaryRequest(seats) } } else { debouncedTrackUserEnterSeatNumberEvent.cancel() debouncedCostSummaryRequest.cancel() + cancelCostSummaryRequest() + resetCostSummaryData() } - resetCostSummaryData() setShouldContactSales(shouldContactSales) } @@ -374,7 +383,6 @@ function AddSeats() { required className="w-25" name="seats" - disabled={isLoadingCostSummary} onChange={handleSeatsChange} isInvalid={Boolean(addSeatsInputError)} /> diff --git a/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx index 21e19ce257..ee7f0510c2 100644 --- a/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx +++ b/services/web/frontend/js/features/group-management/components/add-seats/cost-summary.tsx @@ -28,7 +28,7 @@ function CostSummary({ subscriptionChange, totalLicenses }: CostSummaryProps) { data-testid="cost-summary" > -
+
{t('cost_summary')}
{subscriptionChange ? ( = { + status: 'idle' | 'pending' | 'resolved' | 'rejected' + data: Nullable + error: Nullable +} +type Action = Partial> +type AsyncRunner = (signal: AbortSignal) => Promise + +const defaultInitialState: State = { + status: 'idle', + data: null, + error: null, +} + +const abortError = new Error('Aborted by the caller') +abortError.name = 'AbortError' + +function useAsync( + initialState?: Partial> +) { + const initialStateRef = React.useRef({ + ...defaultInitialState, + ...initialState, + }) + + // Use a Set to track all active AbortController instances + const abortControllerSetRef = React.useRef>(new Set()) + + const [{ status, data, error }, setState] = React.useReducer( + (state: State, action: Action) => ({ ...state, ...action }), + initialStateRef.current + ) + + const safeSetState = useSafeDispatch(setState) + + const setData = React.useCallback( + (data: Nullable) => safeSetState({ data, status: 'resolved' }), + [safeSetState] + ) + + const setError = React.useCallback( + (error: Nullable) => safeSetState({ error, status: 'rejected' }), + [safeSetState] + ) + + const reset = React.useCallback( + () => safeSetState(initialStateRef.current), + [safeSetState] + ) + + const cancelAll = React.useCallback(() => { + // Abort all controllers in the set and clear it + abortControllerSetRef.current.forEach(controller => controller.abort()) + abortControllerSetRef.current.clear() + }, []) + + const runAsync = React.useCallback( + (asyncRunner: AsyncRunner) => { + safeSetState({ status: 'pending' }) + + const controller = new AbortController() + abortControllerSetRef.current.add(controller) + + // The original promise is now created using the provided factory function, + // which receives the signal for cancellation. + const promise = asyncRunner(controller.signal) + + const abortPromise = new Promise((_resolve, reject) => { + controller.signal.addEventListener('abort', () => { + reject(abortError) + }) + }) + + return Promise.race([promise, abortPromise]) + .then( + data => { + setData(data) + return data + }, + error => { + if (error !== abortError) { + setError(error) + } + return Promise.reject(error) + } + ) + .finally(() => { + // Remove the controller from the set, whether it succeeded or failed + abortControllerSetRef.current.delete(controller) + }) + }, + [safeSetState, setData, setError] + ) + + // Abort all requests when the component unmounts to prevent memory leaks + React.useEffect(() => { + return () => { + cancelAll() + } + }, [cancelAll]) + + return { + isIdle: status === 'idle', + isLoading: status === 'pending', + isError: status === 'rejected', + isSuccess: status === 'resolved', + setData, + setError, + error, + status, + data, + runAsync, + reset, + cancelAll, + } +} + +export default useAsync +export type UseAsyncReturnType = ReturnType +export { useAsync, abortError } diff --git a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx index f26357d842..c8e305cacb 100644 --- a/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/add-seats.spec.tsx @@ -3,6 +3,7 @@ import AddSeats, { MAX_NUMBER_OF_PO_NUMBER_CHARACTERS, } from '@/features/group-management/components/add-seats/add-seats' import { SplitTestProvider } from '@/shared/context/split-test-context' +import { cloneDeep } from 'lodash' describe('', function () { beforeEach(function () { @@ -409,6 +410,40 @@ describe('', function () { }) }) + it('handles double digit numbers of licenses gracefully', function () { + const { promise, resolve } = Promise.withResolvers() + const body = cloneDeep(this.body) + cy.intercept( + 'POST', + '/user/subscription/group/add-users/preview', + async req => { + await promise + // make the response reflect back whatever quantity was sent in the request + // we don't really care about the rest of the body for this test + const { adding } = req.body + body.change.addOn.quantity = body.change.addOn.prevQuantity + adding + req.reply({ + statusCode: 200, + body, + }) + } + ).as('addUsersRequest') + + cy.get('@input').type('1') + cy.get('@input').type('2') + resolve() + + cy.findByTestId('adding-licenses-summary').within(() => { + cy.findByText((_, el) => + Boolean( + el?.textContent?.includes( + 'You’re adding 12 licenses to your plan giving you a total of 17 licenses' + ) + ) + ) + }) + }) + describe('request', function () { afterEach(function () { cy.findByRole('link', { name: /go to subscriptions/i }).should(