Skip to content

Commit ee4a5a0

Browse files
Adding Toasts (#17030)
* wip for toasts * set max width * getting toasts working * update workspace timeout ui and add toast * put in a portal * adding some aria props * renaming to toast() * improve mobile styles * shift dotfiles repo update into mutation * remove test button * Update components/dashboard/src/user-settings/Preferences.tsx Co-authored-by: George Tsiolis <[email protected]> * Update components/dashboard/src/user-settings/Preferences.tsx Co-authored-by: George Tsiolis <[email protected]> * Adjusting styling per PR feedback * don't hide toasts on hover --------- Co-authored-by: George Tsiolis <[email protected]>
1 parent 2401a18 commit ee4a5a0

File tree

8 files changed

+354
-76
lines changed

8 files changed

+354
-76
lines changed
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 classNames from "classnames";
8+
import { FC, useCallback, useEffect, useRef } from "react";
9+
import { useId } from "../../hooks/useId";
10+
import { ToastEntry } from "./reducer";
11+
12+
type Props = ToastEntry & {
13+
onRemove: (id: string) => void;
14+
};
15+
16+
export const Toast: FC<Props> = ({ id, message, duration = 5000, autoHide = true, onRemove }) => {
17+
const elId = useId();
18+
const hideTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
19+
20+
const handleRemove = useCallback(
21+
(e) => {
22+
e.preventDefault();
23+
24+
onRemove(id);
25+
},
26+
[id, onRemove],
27+
);
28+
29+
useEffect(() => {
30+
if (!autoHide) {
31+
return;
32+
}
33+
34+
hideTimeout.current = setTimeout(() => {
35+
onRemove(id);
36+
}, duration);
37+
38+
return () => {
39+
if (hideTimeout.current) {
40+
clearTimeout(hideTimeout.current);
41+
}
42+
};
43+
// eslint-disable-next-line react-hooks/exhaustive-deps
44+
}, []);
45+
46+
const onMouseEnter = useCallback(() => {
47+
if (hideTimeout.current) {
48+
clearTimeout(hideTimeout.current);
49+
}
50+
}, []);
51+
52+
const onMouseLeave = useCallback(() => {
53+
if (!autoHide) {
54+
return;
55+
}
56+
57+
if (hideTimeout.current) {
58+
clearTimeout(hideTimeout.current);
59+
}
60+
61+
hideTimeout.current = setTimeout(() => {
62+
onRemove(id);
63+
}, duration);
64+
}, [autoHide, duration, id, onRemove]);
65+
66+
return (
67+
<div
68+
className={classNames(
69+
"relative flex justify-between items-center",
70+
"w-full md:w-96 max-w-full",
71+
"p-4 md:rounded-md",
72+
"bg-gray-800 dark:bg-gray-100",
73+
"text-white dark:text-gray-800",
74+
"transition-transform animate-toast-in-right",
75+
)}
76+
onMouseEnter={onMouseEnter}
77+
onMouseLeave={onMouseLeave}
78+
role="alert"
79+
aria-labelledby={elId}
80+
>
81+
<p className="text-white dark:text-gray-800" id={elId}>
82+
{message}
83+
</p>
84+
<button
85+
className={classNames(
86+
"cursor-pointer p-2",
87+
"bg-transparent hover:bg-transparent",
88+
"text-white hover:text-gray-300 dark:text-gray-800 dark:hover:text-gray-600",
89+
)}
90+
onClick={handleRemove}
91+
>
92+
<svg version="1.1" width="10px" height="10px" viewBox="0 0 100 100">
93+
<line x1="0" y1="0" x2="100" y2="100" stroke="currentColor" strokeWidth="20" />
94+
<line x1="0" y1="100" x2="100" y2="0" stroke="currentColor" strokeWidth="20" />
95+
</svg>
96+
</button>
97+
</div>
98+
);
99+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 classNames from "classnames";
8+
import { createContext, FC, memo, useCallback, useContext, useMemo, useReducer } from "react";
9+
import { Portal } from "react-portal";
10+
import { ToastEntry, toastReducer } from "./reducer";
11+
import { Toast } from "./Toast";
12+
13+
type ToastFnProps = string | (Pick<ToastEntry, "message"> & Partial<ToastEntry>);
14+
15+
const ToastContext = createContext<{
16+
toast: (toast: ToastFnProps, opts?: Partial<ToastEntry>) => void;
17+
}>({
18+
toast: () => undefined,
19+
});
20+
21+
export const useToast = () => {
22+
return useContext(ToastContext);
23+
};
24+
25+
export const ToastContextProvider: FC = ({ children }) => {
26+
const [toasts, dispatch] = useReducer(toastReducer, []);
27+
28+
const removeToast = useCallback((id) => {
29+
dispatch({ type: "remove", id });
30+
}, []);
31+
32+
const addToast = useCallback((message: ToastFnProps, opts = {}) => {
33+
let newToast: ToastEntry = {
34+
...(typeof message === "string"
35+
? {
36+
id: `${Math.random()}`,
37+
message,
38+
}
39+
: {
40+
id: `${Math.random()}`,
41+
...message,
42+
}),
43+
...opts,
44+
};
45+
46+
dispatch({ type: "add", toast: newToast });
47+
}, []);
48+
49+
const ctxValue = useMemo(() => ({ toast: addToast }), [addToast]);
50+
51+
return (
52+
<ToastContext.Provider value={ctxValue}>
53+
{children}
54+
<ToastsList toasts={toasts} onRemove={removeToast} />
55+
</ToastContext.Provider>
56+
);
57+
};
58+
59+
type ToastsListProps = {
60+
toasts: ToastEntry[];
61+
onRemove: (id: string) => void;
62+
};
63+
const ToastsList: FC<ToastsListProps> = memo(({ toasts, onRemove }) => {
64+
return (
65+
<Portal>
66+
<div
67+
className={classNames(
68+
"fixed box-border space-y-2",
69+
"w-full md:w-auto",
70+
"bottom-0 md:bottom-2 right-0 md:right-2",
71+
)}
72+
tabIndex={-1}
73+
role="region"
74+
aria-label="Notifications"
75+
>
76+
{toasts.map((toast) => {
77+
return <Toast key={toast.id} {...toast} onRemove={onRemove} />;
78+
})}
79+
</div>
80+
</Portal>
81+
);
82+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
export type ToastEntry = {
8+
id: string;
9+
message: string;
10+
duration?: number;
11+
autoHide?: boolean;
12+
};
13+
14+
type ToastAction =
15+
| {
16+
type: "add";
17+
toast: ToastEntry;
18+
}
19+
| {
20+
type: "remove";
21+
id: string;
22+
};
23+
export const toastReducer = (state: ToastEntry[], action: ToastAction) => {
24+
if (action.type === "add") {
25+
return [...state, action.toast];
26+
}
27+
28+
if (action.type === "remove") {
29+
return state.filter((toast) => toast.id !== action.id);
30+
}
31+
32+
return state;
33+
};

components/dashboard/src/data/current-user/update-mutation.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77
import { User } from "@gitpod/gitpod-protocol";
88
import { useMutation } from "@tanstack/react-query";
9+
import { trackEvent } from "../../Analytics";
910
import { getGitpodService } from "../../service/service";
11+
import { useCurrentUser } from "../../user-context";
1012

1113
type UpdateCurrentUserArgs = Partial<User>;
1214

@@ -17,3 +19,37 @@ export const useUpdateCurrentUserMutation = () => {
1719
},
1820
});
1921
};
22+
23+
export const useUpdateCurrentUserDotfileRepoMutation = () => {
24+
const user = useCurrentUser();
25+
const updateUser = useUpdateCurrentUserMutation();
26+
27+
return useMutation({
28+
mutationFn: async (dotfileRepo: string) => {
29+
if (!user) {
30+
throw new Error("No user present");
31+
}
32+
33+
const additionalData = {
34+
...(user.additionalData || {}),
35+
dotfileRepo,
36+
};
37+
const updatedUser = await updateUser.mutateAsync({ additionalData });
38+
39+
return updatedUser;
40+
},
41+
onMutate: async () => {
42+
return {
43+
previousDotfileRepo: user?.additionalData?.dotfileRepo || "",
44+
};
45+
},
46+
onSuccess: (updatedUser, _, context) => {
47+
if (updatedUser?.additionalData?.dotfileRepo !== context?.previousDotfileRepo) {
48+
trackEvent("dotfile_repo_changed", {
49+
previous: context?.previousDotfileRepo ?? "",
50+
current: updatedUser?.additionalData?.dotfileRepo ?? "",
51+
});
52+
}
53+
},
54+
});
55+
};

components/dashboard/src/index.tsx

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { setupQueryClientProvider } from "./data/setup";
2525
import { ConfettiContextProvider } from "./contexts/ConfettiContext";
2626
import { GitpodErrorBoundary } from "./components/ErrorBoundary";
2727
import "./index.css";
28+
import { ToastContextProvider } from "./components/toasts/Toasts";
2829

2930
const bootApp = () => {
3031
// gitpod.io specific boot logic
@@ -59,25 +60,27 @@ const bootApp = () => {
5960
<GitpodErrorBoundary>
6061
<GitpodQueryClientProvider>
6162
<ConfettiContextProvider>
62-
<UserContextProvider>
63-
<AdminContextProvider>
64-
<PaymentContextProvider>
65-
<LicenseContextProvider>
66-
<ProjectContextProvider>
67-
<ThemeContextProvider>
68-
<BrowserRouter>
69-
<StartWorkspaceModalContextProvider>
70-
<FeatureFlagContextProvider>
71-
<App />
72-
</FeatureFlagContextProvider>
73-
</StartWorkspaceModalContextProvider>
74-
</BrowserRouter>
75-
</ThemeContextProvider>
76-
</ProjectContextProvider>
77-
</LicenseContextProvider>
78-
</PaymentContextProvider>
79-
</AdminContextProvider>
80-
</UserContextProvider>
63+
<ToastContextProvider>
64+
<UserContextProvider>
65+
<AdminContextProvider>
66+
<PaymentContextProvider>
67+
<LicenseContextProvider>
68+
<ProjectContextProvider>
69+
<ThemeContextProvider>
70+
<BrowserRouter>
71+
<StartWorkspaceModalContextProvider>
72+
<FeatureFlagContextProvider>
73+
<App />
74+
</FeatureFlagContextProvider>
75+
</StartWorkspaceModalContextProvider>
76+
</BrowserRouter>
77+
</ThemeContextProvider>
78+
</ProjectContextProvider>
79+
</LicenseContextProvider>
80+
</PaymentContextProvider>
81+
</AdminContextProvider>
82+
</UserContextProvider>
83+
</ToastContextProvider>
8184
</ConfettiContextProvider>
8285
</GitpodQueryClientProvider>
8386
</GitpodErrorBoundary>

0 commit comments

Comments
 (0)