Fix blank pages and add error recovery in onboarding (#23069)
Onboarding showed a blank page in two places: before the plan/payment step, and briefly after the welcome animation. `ChooseYourPlan` returned `null` while its `ListPlans` query loaded, leaving the step empty during the crossfade. It now renders the step loader, and the plans query is warmed from an earlier onboarding step so the content is usually already there. The post-completion redirect lands transiently on `/`, whose route element was `<></>`, so the welcome animation could reveal an empty page. It now renders a skeleton, and the `null` Suspense fallbacks on payment-success and book-call are replaced too. A failed `ListPlans` query was worse than a blank frame: `PLAN_REQUIRED` redirects every route back to itself, so the user was locked out of the product with no way to retry. That step and the billing settings page now show a retryable error state. One related fix found on the way: `BlankLayout` had no error boundary, so a render-time throw anywhere in sign-in or onboarding took down the whole app. `DefaultLayout` already had one. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23069?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+3
-1
@@ -8,6 +8,7 @@ import { useSnackBarOnQueryError } from '@/apollo/hooks/useSnackBarOnQueryError'
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { SettingsBillingContentSkeleton } from '@/settings/billing/components/SettingsBillingContentSkeleton';
|
||||
import { SettingsBillingPlansErrorState } from '@/settings/billing/components/SettingsBillingPlansErrorState';
|
||||
import { SettingsBillingTabBar } from '@/settings/billing/components/SettingsBillingTabBar';
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
@@ -29,6 +30,7 @@ export const SettingsBillingPageLayout = ({
|
||||
error: plansError,
|
||||
isPlansLoaded,
|
||||
loading: arePlansLoading,
|
||||
refetch: refetchPlans,
|
||||
} = usePlans({ skip: !isBillingEnabled });
|
||||
|
||||
useSnackBarOnQueryError(plansError, t`Failed to load billing plans`);
|
||||
@@ -56,7 +58,7 @@ export const SettingsBillingPageLayout = ({
|
||||
) : isPlansLoaded ? (
|
||||
children
|
||||
) : (
|
||||
<></>
|
||||
<SettingsBillingPlansErrorState onRetry={() => void refetchPlans()} />
|
||||
)}
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
AnimatedPlaceholder,
|
||||
AnimatedPlaceholderErrorContainer,
|
||||
AnimatedPlaceholderErrorSubTitle,
|
||||
AnimatedPlaceholderErrorTextContainer,
|
||||
AnimatedPlaceholderErrorTitle,
|
||||
} from 'twenty-ui/feedback';
|
||||
import { IconRefresh } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
type SettingsBillingPlansErrorStateProps = {
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
export const SettingsBillingPlansErrorState = ({
|
||||
onRetry,
|
||||
}: SettingsBillingPlansErrorStateProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<AnimatedPlaceholderErrorContainer>
|
||||
<AnimatedPlaceholder type="errorIndex" />
|
||||
<AnimatedPlaceholderErrorTextContainer>
|
||||
<AnimatedPlaceholderErrorTitle>
|
||||
{t`We couldn't load the plans`}
|
||||
</AnimatedPlaceholderErrorTitle>
|
||||
<AnimatedPlaceholderErrorSubTitle>
|
||||
{t`Something went wrong while contacting our billing service.`}
|
||||
</AnimatedPlaceholderErrorSubTitle>
|
||||
</AnimatedPlaceholderErrorTextContainer>
|
||||
<Button
|
||||
Icon={IconRefresh}
|
||||
title={t`Try again`}
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
/>
|
||||
</AnimatedPlaceholderErrorContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
|
||||
describe('usePlans', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof usePlans).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MockedProvider } from '@apollo/client/testing/react';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { type ReactNode } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { usePlans } from '@/settings/billing/hooks/usePlans';
|
||||
import { ListPlansDocument } from '~/generated-metadata/graphql';
|
||||
|
||||
const listPlansSuccessMock = {
|
||||
request: { query: ListPlansDocument },
|
||||
result: { data: { listPlans: [] } },
|
||||
};
|
||||
|
||||
const listPlansSlowSuccessMock = {
|
||||
...listPlansSuccessMock,
|
||||
delay: 50,
|
||||
};
|
||||
|
||||
const listPlansErrorMock = {
|
||||
request: { query: ListPlansDocument },
|
||||
result: { errors: [new GraphQLError('Internal server error')] },
|
||||
};
|
||||
|
||||
const createWrapper =
|
||||
(mocks: readonly unknown[]) =>
|
||||
({ children }: { children: ReactNode }) => (
|
||||
<MockedProvider mocks={mocks as never}>{children}</MockedProvider>
|
||||
);
|
||||
|
||||
describe('usePlans', () => {
|
||||
it('should expose the plans once the query resolves', async () => {
|
||||
const { result } = renderHook(() => usePlans(), {
|
||||
wrapper: createWrapper([listPlansSuccessMock]),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isPlansLoaded).toBe(true));
|
||||
|
||||
expect(result.current.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should surface the error and leave the plans unloaded when the query fails', async () => {
|
||||
const { result } = renderHook(() => usePlans(), {
|
||||
wrapper: createWrapper([listPlansErrorMock]),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeDefined());
|
||||
|
||||
expect(result.current.isPlansLoaded).toBe(false);
|
||||
});
|
||||
|
||||
it('should report loading and clear the error while a refetch is in flight', async () => {
|
||||
const renders: { loading: boolean; hasError: boolean }[] = [];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => {
|
||||
const plans = usePlans();
|
||||
renders.push({
|
||||
loading: plans.loading,
|
||||
hasError: isDefined(plans.error),
|
||||
});
|
||||
return plans;
|
||||
},
|
||||
{
|
||||
wrapper: createWrapper([listPlansErrorMock, listPlansSlowSuccessMock]),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeDefined());
|
||||
|
||||
renders.length = 0;
|
||||
|
||||
let refetching: Promise<unknown> | undefined;
|
||||
act(() => {
|
||||
refetching = result.current.refetch();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await refetching;
|
||||
});
|
||||
|
||||
expect(renders).toContainEqual({ loading: true, hasError: false });
|
||||
|
||||
await waitFor(() => expect(result.current.isPlansLoaded).toBe(true));
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('should not run the query when skipped', () => {
|
||||
const { result } = renderHook(() => usePlans({ skip: true }), {
|
||||
wrapper: createWrapper([]),
|
||||
});
|
||||
|
||||
expect(result.current.isPlansLoaded).toBe(false);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ type UsePlansOptions = {
|
||||
};
|
||||
|
||||
export const usePlans = (options?: UsePlansOptions) => {
|
||||
const { data, loading, error } = useQuery(ListPlansDocument, {
|
||||
const { data, loading, error, refetch } = useQuery(ListPlansDocument, {
|
||||
skip: options?.skip,
|
||||
});
|
||||
|
||||
@@ -18,5 +18,5 @@ export const usePlans = (options?: UsePlansOptions) => {
|
||||
return data.listPlans;
|
||||
};
|
||||
|
||||
return { loading, error, isPlansLoaded, listPlans };
|
||||
return { loading, error, isPlansLoaded, listPlans, refetch };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user