Skip to content

Commit 13db847

Browse files
Improve app and org loading states (#16984)
* use separate query for org billing mode * remove user billing mode from UserContext * rename to account for no teams anymore * adding content for app loading * add top level error boundary * fix empty message layout * adding comment * no need to export * don't throw all errors * adding code to error boundary rendering * default to null instead of undefined * use user state from UserContext for now * reduce to 2 seconds, and drop bouncing animation
1 parent c0a5b9a commit 13db847

19 files changed

+230
-151
lines changed

components/dashboard/src/App.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,31 @@
55
*/
66

77
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
8-
import React, { FunctionComponent, Suspense } from "react";
8+
import React, { FC, Suspense } from "react";
99
import { AppLoading } from "./app/AppLoading";
1010
import { AppRoutes } from "./app/AppRoutes";
11+
import { GitpodErrorBoundary } from "./components/ErrorBoundary";
1112
import { useCurrentOrg } from "./data/organizations/orgs-query";
1213
import { useAnalyticsTracking } from "./hooks/use-analytics-tracking";
13-
import { useUserAndTeamsLoader } from "./hooks/use-user-and-teams-loader";
14+
import { useUserLoader } from "./hooks/use-user-loader";
1415
import { Login } from "./Login";
1516
import { isGitpodIo } from "./utils";
1617

1718
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup"));
1819

20+
// Wrap the App in an ErrorBoundary to catch User/Org loading errors
21+
// This will also catch any errors that happen to bubble all the way up to the top
22+
const AppWithErrorBoundary: FC = () => {
23+
return (
24+
<GitpodErrorBoundary>
25+
<App />
26+
</GitpodErrorBoundary>
27+
);
28+
};
29+
1930
// Top level Dashboard App component
20-
const App: FunctionComponent = () => {
21-
const { user, isSetupRequired, loading } = useUserAndTeamsLoader();
31+
const App: FC = () => {
32+
const { user, isSetupRequired, loading } = useUserLoader();
2233
const currentOrgQuery = useCurrentOrg();
2334

2435
// Setup analytics/tracking
@@ -67,4 +78,4 @@ const App: FunctionComponent = () => {
6778
);
6879
};
6980

70-
export default App;
81+
export default AppWithErrorBoundary;

components/dashboard/src/app/AppLoading.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,19 @@
55
*/
66

77
import { FunctionComponent } from "react";
8+
import { Delayed } from "../components/Delayed";
9+
import { Heading3, Subheading } from "../components/typography/headings";
10+
import gitpodIcon from "../icons/gitpod.svg";
811

9-
// TODO: Would be great to show a loading UI if it's been loading for awhile
1012
export const AppLoading: FunctionComponent = () => {
11-
return <></>;
13+
return (
14+
// Wait 2 seconds before showing the loading screen to avoid flashing it too quickly
15+
<Delayed wait={2000}>
16+
<div className="flex flex-col justify-center items-center w-full h-screen space-y-4">
17+
<img src={gitpodIcon} alt="Gitpod's logo" className={"h-16 flex-shrink-0"} />
18+
<Heading3>Just getting a few more things ready</Heading3>
19+
<Subheading>hang in there...</Subheading>
20+
</div>
21+
</Delayed>
22+
);
1223
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { FC, useEffect, useState } from "react";
8+
9+
type Props = {
10+
wait?: number;
11+
};
12+
export const Delayed: FC<Props> = ({ wait = 1000, children }) => {
13+
const [show, setShow] = useState(false);
14+
15+
useEffect(() => {
16+
const timeout = setTimeout(() => setShow(true), wait);
17+
return () => clearTimeout(timeout);
18+
// eslint-disable-next-line react-hooks/exhaustive-deps
19+
}, []);
20+
21+
if (!show) {
22+
return null;
23+
}
24+
25+
return <>{children}</>;
26+
};

components/dashboard/src/components/EmptyMessage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
*/
66

77
import classNames from "classnames";
8-
import { FC, useCallback } from "react";
8+
import { FC, ReactNode, useCallback } from "react";
99
import { Button } from "./Button";
1010
import { Heading2, Subheading } from "./typography/headings";
1111

1212
type Props = {
13-
title: string;
14-
subtitle?: string;
13+
title: ReactNode;
14+
subtitle?: ReactNode;
1515
buttonText?: string;
1616
onClick?: () => void;
1717
className?: string;

components/dashboard/src/components/ErrorBoundary.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,18 @@ export const GitpodErrorBoundary: FC = ({ children }) => {
1818
);
1919
};
2020

21+
type CaughtError = Error & { code?: number };
22+
2123
export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBoundary }) => {
24+
// adjust typing, as we may have caught an api error here w/ a code property
25+
const caughtError = error as CaughtError;
26+
2227
const emailSubject = encodeURIComponent("Gitpod Dashboard Error");
23-
const emailBody = encodeURIComponent(`\n\nError: ${error.message}`);
28+
let emailBodyStr = `\n\nError: ${caughtError.message}`;
29+
if (caughtError.code) {
30+
emailBodyStr += `\nCode: ${caughtError.code}`;
31+
}
32+
const emailBody = encodeURIComponent(emailBodyStr);
2433

2534
return (
2635
<div role="alert" className="app-container mt-14 flex flex-col items-center justify-center space-y-6">
@@ -36,7 +45,14 @@ export const DefaultErrorFallback: FC<FallbackProps> = ({ error, resetErrorBound
3645
<div>
3746
<button onClick={resetErrorBoundary}>Reload</button>
3847
</div>
39-
{error.message && <pre>{error.message}</pre>}
48+
<div>
49+
{caughtError.code && (
50+
<span>
51+
<strong>Code:</strong> {caughtError.code}
52+
</span>
53+
)}
54+
{caughtError.message && <pre>{caughtError.message}</pre>}
55+
</div>
4056
</div>
4157
);
4258
};

components/dashboard/src/data/organizations/orgs-query.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,16 @@
55
*/
66

77
import { Organization, OrgMemberInfo, User } from "@gitpod/gitpod-protocol";
8-
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
98
import { useQuery, useQueryClient } from "@tanstack/react-query";
10-
import { useCallback, useContext } from "react";
9+
import { useCallback } from "react";
1110
import { useLocation } from "react-router";
1211
import { publicApiTeamMembersToProtocol, publicApiTeamToProtocol, teamsService } from "../../service/public-api";
13-
import { getGitpodService } from "../../service/service";
14-
import { useCurrentUser, UserContext } from "../../user-context";
12+
import { useCurrentUser } from "../../user-context";
1513
import { getUserBillingModeQueryKey } from "../billing-mode/user-billing-mode-query";
1614
import { noPersistence } from "../setup";
1715

1816
export interface OrganizationInfo extends Organization {
1917
members: OrgMemberInfo[];
20-
billingMode?: BillingMode;
2118
isOwner: boolean;
2219
invitationId?: string;
2320
}
@@ -34,7 +31,6 @@ export function useOrganizationsInvalidator() {
3431
export function useOrganizations() {
3532
const user = useCurrentUser();
3633
const queryClient = useQueryClient();
37-
const { refreshUserBillingMode } = useContext(UserContext);
3834
const query = useQuery<OrganizationInfo[], Error>(
3935
getQueryKey(user),
4036
async () => {
@@ -47,13 +43,11 @@ export function useOrganizations() {
4743
const response = await teamsService.listTeams({});
4844
const result: OrganizationInfo[] = [];
4945
for (const org of response.teams) {
50-
const billingMode = await getGitpodService().server.getBillingModeForTeam(org.id);
5146
const members = publicApiTeamMembersToProtocol(org.members || []);
5247
const isOwner = members.some((m) => m.role === "owner" && m.userId === user?.id);
5348
result.push({
5449
...publicApiTeamToProtocol(org),
5550
members,
56-
billingMode,
5751
isOwner,
5852
invitationId: org.teamInvitation?.id,
5953
});
@@ -65,16 +59,15 @@ export function useOrganizations() {
6559
if (!user) {
6660
return;
6761
}
62+
6863
// refresh user billing mode to update the billing mode in the user context as it depends on the orgs
69-
refreshUserBillingMode();
7064
queryClient.invalidateQueries(getUserBillingModeQueryKey(user.id));
7165
},
72-
onError: (err) => {
73-
console.error("useOrganizations", err);
74-
},
7566
enabled: !!user,
7667
cacheTime: 1000 * 60 * 60 * 1, // 1 hour
7768
staleTime: 1000 * 60 * 60 * 1, // 1 hour
69+
// We'll let an ErrorBoundary catch the error
70+
useErrorBoundary: true,
7871
},
7972
);
8073
return query;

components/dashboard/src/data/setup.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
PersistQueryClientProvider,
1212
PersistQueryClientProviderProps,
1313
} from "@tanstack/react-query-persist-client";
14-
import { QueryClient, QueryKey } from "@tanstack/react-query";
14+
import { QueryCache, QueryClient, QueryKey } from "@tanstack/react-query";
1515
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
1616
import { FunctionComponent } from "react";
1717

@@ -28,7 +28,14 @@ export function isNoPersistence(queryKey: QueryKey): boolean {
2828
}
2929

3030
export const setupQueryClientProvider = () => {
31-
const client = new QueryClient();
31+
const client = new QueryClient({
32+
queryCache: new QueryCache({
33+
// log any errors our queries throw
34+
onError: (error) => {
35+
console.error(error);
36+
},
37+
}),
38+
});
3239
const queryClientPersister = createIDBPersister();
3340

3441
const persistOptions: PersistQueryClientProviderProps["persistOptions"] = {

components/dashboard/src/hooks/use-analytics-tracking.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ export const useAnalyticsTracking = () => {
1313

1414
// listen and notify Segment of client-side path updates
1515
useEffect(() => {
16+
//store current path to have access to previous when path changes
17+
const w = window as any;
18+
const _gp = w._gp || (w._gp = {});
19+
_gp.path = window.location.pathname;
20+
1621
return history.listen((location: any) => {
1722
const path = window.location.pathname;
1823
trackPathChange({

components/dashboard/src/hooks/use-user-and-teams-loader.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useState, useContext } from "react";
8+
import { User } from "@gitpod/gitpod-protocol";
9+
import { UserContext } from "../user-context";
10+
import { getGitpodService } from "../service/service";
11+
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
12+
import { trackLocation } from "../Analytics";
13+
import { refreshSearchData } from "../components/RepositoryFinder";
14+
import { useQuery } from "@tanstack/react-query";
15+
import { noPersistence } from "../data/setup";
16+
17+
export const useUserLoader = () => {
18+
const { user, setUser } = useContext(UserContext);
19+
const [isSetupRequired, setSetupRequired] = useState(false);
20+
21+
// For now, we're using the user context to store the user, but letting react-query handle the loading
22+
// In the future, we should remove the user context and use react-query to access the user
23+
const { isLoading } = useQuery({
24+
queryKey: noPersistence(["current-user"]),
25+
queryFn: async () => {
26+
let user: User | undefined;
27+
try {
28+
user = await getGitpodService().server.getLoggedInUser();
29+
setUser(user);
30+
refreshSearchData();
31+
} catch (error) {
32+
if (error && "code" in error) {
33+
if (error.code === ErrorCodes.SETUP_REQUIRED) {
34+
setSetupRequired(true);
35+
return;
36+
}
37+
38+
// If it was a server error, throw it so we can catch it with an ErrorBoundary
39+
if (error.code >= 500) {
40+
throw error;
41+
}
42+
43+
// Other errors will treat user as needing to log in
44+
}
45+
} finally {
46+
trackLocation(!!user);
47+
}
48+
49+
return user || null;
50+
},
51+
// We'll let an ErrorBoundary catch the error
52+
useErrorBoundary: true,
53+
cacheTime: 1000 * 60 * 60 * 1, // 1 hour
54+
staleTime: 1000 * 60 * 60 * 1, // 1 hour
55+
});
56+
57+
return { user, loading: isLoading, isSetupRequired };
58+
};

components/dashboard/src/menu/OrganizationSelector.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
1212
import { useUserBillingMode } from "../data/billing-mode/user-billing-mode-query";
1313
import { useFeatureFlags } from "../contexts/FeatureFlagContext";
1414
import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query";
15+
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
1516

1617
export interface OrganizationSelectorProps {}
1718

@@ -20,6 +21,7 @@ export default function OrganizationSelector(p: OrganizationSelectorProps) {
2021
const orgs = useOrganizations();
2122
const currentOrg = useCurrentOrg();
2223
const { data: userBillingMode } = useUserBillingMode();
24+
const { data: orgBillingMode } = useOrgBillingMode();
2325
const { showUsageView } = useFeatureFlags();
2426

2527
const userFullName = user?.fullName || user?.name || "...";
@@ -70,10 +72,7 @@ export default function OrganizationSelector(p: OrganizationSelectorProps) {
7072
BillingMode.showUsageBasedBilling(userBillingMode) &&
7173
!user?.additionalData?.isMigratedToTeamOnlyAttribution;
7274

73-
const showUsageForOrg =
74-
currentOrg.data &&
75-
currentOrg.data.isOwner &&
76-
(currentOrg.data.billingMode?.mode === "usage-based" || showUsageView);
75+
const showUsageForOrg = currentOrg.data?.isOwner && (orgBillingMode?.mode === "usage-based" || showUsageView);
7776

7877
if (showUsageForPersonalAccount || showUsageForOrg) {
7978
linkEntries.push({

0 commit comments

Comments
 (0)