From ee4a5a0932c8358afd2843f6dc75dba74d4bf351 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 6 Apr 2023 13:55:46 +0200 Subject: [PATCH] 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 * Update components/dashboard/src/user-settings/Preferences.tsx Co-authored-by: George Tsiolis * Adjusting styling per PR feedback * don't hide toasts on hover --------- Co-authored-by: George Tsiolis --- .../dashboard/src/components/toasts/Toast.tsx | 99 ++++++++++++++ .../src/components/toasts/Toasts.tsx | 82 +++++++++++ .../src/components/toasts/reducer.ts | 33 +++++ .../src/data/current-user/update-mutation.ts | 36 +++++ components/dashboard/src/index.tsx | 41 +++--- .../src/user-settings/Preferences.tsx | 128 ++++++++++-------- .../dashboard/src/user-settings/SelectIDE.tsx | 2 +- components/dashboard/tailwind.config.js | 9 ++ 8 files changed, 354 insertions(+), 76 deletions(-) create mode 100644 components/dashboard/src/components/toasts/Toast.tsx create mode 100644 components/dashboard/src/components/toasts/Toasts.tsx create mode 100644 components/dashboard/src/components/toasts/reducer.ts diff --git a/components/dashboard/src/components/toasts/Toast.tsx b/components/dashboard/src/components/toasts/Toast.tsx new file mode 100644 index 00000000000000..eb9ae607dfcefe --- /dev/null +++ b/components/dashboard/src/components/toasts/Toast.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import classNames from "classnames"; +import { FC, useCallback, useEffect, useRef } from "react"; +import { useId } from "../../hooks/useId"; +import { ToastEntry } from "./reducer"; + +type Props = ToastEntry & { + onRemove: (id: string) => void; +}; + +export const Toast: FC = ({ id, message, duration = 5000, autoHide = true, onRemove }) => { + const elId = useId(); + const hideTimeout = useRef | null>(null); + + const handleRemove = useCallback( + (e) => { + e.preventDefault(); + + onRemove(id); + }, + [id, onRemove], + ); + + useEffect(() => { + if (!autoHide) { + return; + } + + hideTimeout.current = setTimeout(() => { + onRemove(id); + }, duration); + + return () => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onMouseEnter = useCallback(() => { + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + }, []); + + const onMouseLeave = useCallback(() => { + if (!autoHide) { + return; + } + + if (hideTimeout.current) { + clearTimeout(hideTimeout.current); + } + + hideTimeout.current = setTimeout(() => { + onRemove(id); + }, duration); + }, [autoHide, duration, id, onRemove]); + + return ( +
+

+ {message} +

+ +
+ ); +}; diff --git a/components/dashboard/src/components/toasts/Toasts.tsx b/components/dashboard/src/components/toasts/Toasts.tsx new file mode 100644 index 00000000000000..f914cebfc1af0f --- /dev/null +++ b/components/dashboard/src/components/toasts/Toasts.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import classNames from "classnames"; +import { createContext, FC, memo, useCallback, useContext, useMemo, useReducer } from "react"; +import { Portal } from "react-portal"; +import { ToastEntry, toastReducer } from "./reducer"; +import { Toast } from "./Toast"; + +type ToastFnProps = string | (Pick & Partial); + +const ToastContext = createContext<{ + toast: (toast: ToastFnProps, opts?: Partial) => void; +}>({ + toast: () => undefined, +}); + +export const useToast = () => { + return useContext(ToastContext); +}; + +export const ToastContextProvider: FC = ({ children }) => { + const [toasts, dispatch] = useReducer(toastReducer, []); + + const removeToast = useCallback((id) => { + dispatch({ type: "remove", id }); + }, []); + + const addToast = useCallback((message: ToastFnProps, opts = {}) => { + let newToast: ToastEntry = { + ...(typeof message === "string" + ? { + id: `${Math.random()}`, + message, + } + : { + id: `${Math.random()}`, + ...message, + }), + ...opts, + }; + + dispatch({ type: "add", toast: newToast }); + }, []); + + const ctxValue = useMemo(() => ({ toast: addToast }), [addToast]); + + return ( + + {children} + + + ); +}; + +type ToastsListProps = { + toasts: ToastEntry[]; + onRemove: (id: string) => void; +}; +const ToastsList: FC = memo(({ toasts, onRemove }) => { + return ( + +
+ {toasts.map((toast) => { + return ; + })} +
+
+ ); +}); diff --git a/components/dashboard/src/components/toasts/reducer.ts b/components/dashboard/src/components/toasts/reducer.ts new file mode 100644 index 00000000000000..f8ac54672ae911 --- /dev/null +++ b/components/dashboard/src/components/toasts/reducer.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +export type ToastEntry = { + id: string; + message: string; + duration?: number; + autoHide?: boolean; +}; + +type ToastAction = + | { + type: "add"; + toast: ToastEntry; + } + | { + type: "remove"; + id: string; + }; +export const toastReducer = (state: ToastEntry[], action: ToastAction) => { + if (action.type === "add") { + return [...state, action.toast]; + } + + if (action.type === "remove") { + return state.filter((toast) => toast.id !== action.id); + } + + return state; +}; diff --git a/components/dashboard/src/data/current-user/update-mutation.ts b/components/dashboard/src/data/current-user/update-mutation.ts index e683f06f5ddd1d..ec84db5d72b0db 100644 --- a/components/dashboard/src/data/current-user/update-mutation.ts +++ b/components/dashboard/src/data/current-user/update-mutation.ts @@ -6,7 +6,9 @@ import { User } from "@gitpod/gitpod-protocol"; import { useMutation } from "@tanstack/react-query"; +import { trackEvent } from "../../Analytics"; import { getGitpodService } from "../../service/service"; +import { useCurrentUser } from "../../user-context"; type UpdateCurrentUserArgs = Partial; @@ -17,3 +19,37 @@ export const useUpdateCurrentUserMutation = () => { }, }); }; + +export const useUpdateCurrentUserDotfileRepoMutation = () => { + const user = useCurrentUser(); + const updateUser = useUpdateCurrentUserMutation(); + + return useMutation({ + mutationFn: async (dotfileRepo: string) => { + if (!user) { + throw new Error("No user present"); + } + + const additionalData = { + ...(user.additionalData || {}), + dotfileRepo, + }; + const updatedUser = await updateUser.mutateAsync({ additionalData }); + + return updatedUser; + }, + onMutate: async () => { + return { + previousDotfileRepo: user?.additionalData?.dotfileRepo || "", + }; + }, + onSuccess: (updatedUser, _, context) => { + if (updatedUser?.additionalData?.dotfileRepo !== context?.previousDotfileRepo) { + trackEvent("dotfile_repo_changed", { + previous: context?.previousDotfileRepo ?? "", + current: updatedUser?.additionalData?.dotfileRepo ?? "", + }); + } + }, + }); +}; diff --git a/components/dashboard/src/index.tsx b/components/dashboard/src/index.tsx index ca3c7009b3ef3b..a91ac43ba2878b 100644 --- a/components/dashboard/src/index.tsx +++ b/components/dashboard/src/index.tsx @@ -25,6 +25,7 @@ import { setupQueryClientProvider } from "./data/setup"; import { ConfettiContextProvider } from "./contexts/ConfettiContext"; import { GitpodErrorBoundary } from "./components/ErrorBoundary"; import "./index.css"; +import { ToastContextProvider } from "./components/toasts/Toasts"; const bootApp = () => { // gitpod.io specific boot logic @@ -59,25 +60,27 @@ const bootApp = () => { - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/components/dashboard/src/user-settings/Preferences.tsx b/components/dashboard/src/user-settings/Preferences.tsx index 5945e6de645ef4..da3633ce03e957 100644 --- a/components/dashboard/src/user-settings/Preferences.tsx +++ b/components/dashboard/src/user-settings/Preferences.tsx @@ -7,7 +7,6 @@ import { useCallback, useContext, useState } from "react"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; -import { trackEvent } from "../Analytics"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; import { ThemeSelector } from "../components/ThemeSelector"; import Alert from "../components/Alert"; @@ -16,49 +15,56 @@ import { Heading2, Subheading } from "../components/typography/headings"; import { useUserMaySetTimeout } from "../data/current-user/may-set-timeout-query"; import { Button } from "../components/Button"; import SelectIDE from "./SelectIDE"; +import { InputField } from "../components/forms/InputField"; +import { TextInput } from "../components/forms/TextInputField"; +import { useToast } from "../components/toasts/Toasts"; +import { useUpdateCurrentUserDotfileRepoMutation } from "../data/current-user/update-mutation"; export type IDEChangedTrackLocation = "workspace_list" | "workspace_start" | "preferences"; export default function Preferences() { + const { toast } = useToast(); const { user, setUser } = useContext(UserContext); const maySetTimeout = useUserMaySetTimeout(); + const updateDotfileRepo = useUpdateCurrentUserDotfileRepoMutation(); const [dotfileRepo, setDotfileRepo] = useState(user?.additionalData?.dotfileRepo || ""); const [workspaceTimeout, setWorkspaceTimeout] = useState(user?.additionalData?.workspaceTimeout ?? ""); + const [timeoutUpdating, setTimeoutUpdating] = useState(false); const saveDotfileRepo = useCallback( async (e) => { e.preventDefault(); - const prevDotfileRepo = user?.additionalData?.dotfileRepo || ""; - const additionalData = { - ...(user?.additionalData || {}), - dotfileRepo, - }; - const updatedUser = await getGitpodService().server.updateLoggedInUser({ additionalData }); + const updatedUser = await updateDotfileRepo.mutateAsync(dotfileRepo); setUser(updatedUser); - - if (dotfileRepo !== prevDotfileRepo) { - trackEvent("dotfile_repo_changed", { - previous: prevDotfileRepo, - current: dotfileRepo, - }); - } + toast("Your dotfiles repository was updated."); }, - [dotfileRepo, setUser, user?.additionalData], + [updateDotfileRepo, dotfileRepo, setUser, toast], ); const saveWorkspaceTimeout = useCallback( async (e) => { e.preventDefault(); + setTimeoutUpdating(true); + // TODO: Convert this to a mutation try { await getGitpodService().server.updateWorkspaceTimeoutSetting({ workspaceTimeout: workspaceTimeout }); + + // TODO: Once current user is in react-query, we can instead invalidate the query vs. refetching here + const updatedUser = await getGitpodService().server.getLoggedInUser(); + setUser(updatedUser); + + toast("Your default workspace timeout was updated."); } catch (e) { + // TODO: Convert this to an error style toast alert("Cannot set custom workspace timeout: " + e.message); + } finally { + setTimeoutUpdating(false); } }, - [workspaceTimeout], + [toast, setUser, workspaceTimeout], ); return ( @@ -82,34 +88,37 @@ export default function Preferences() { Dotfiles Customize workspaces using dotfiles. +
-

Repository URL

- - setDotfileRepo(e.target.value)} - /> - - -
-

- Add a repository URL that includes dotfiles. Gitpod will -
- clone and install your dotfiles for every new workspace. -

-
+ +
+
+ +
+ +
+
Timeouts Workspaces will stop after a period of inactivity without any user input. -
-

Default Workspace Timeout

+
{!maySetTimeout.isLoading && maySetTimeout.data === false && ( Upgrade organization{" "} @@ -122,24 +131,31 @@ export default function Preferences() { {maySetTimeout.data === true && (
- - setWorkspaceTimeout(e.target.value)} - /> - - -
-

- Use minutes or hours, like 30m or{" "} - 2h. -

-
+ + Use minutes or hours, like 30m or{" "} + 2h + + } + > +
+
+ +
+ +
+
)}
diff --git a/components/dashboard/src/user-settings/SelectIDE.tsx b/components/dashboard/src/user-settings/SelectIDE.tsx index eb4c06ad9bd7e8..1e554cbb5c5c61 100644 --- a/components/dashboard/src/user-settings/SelectIDE.tsx +++ b/components/dashboard/src/user-settings/SelectIDE.tsx @@ -75,7 +75,7 @@ export default function SelectIDE(props: SelectIDEProps) { return ( <> -
+