Skip to content

Commit

Permalink
Adding Toasts (#17030)
Browse files Browse the repository at this point in the history
* 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 <tsiolis.g@gmail.com>

* Update components/dashboard/src/user-settings/Preferences.tsx

Co-authored-by: George Tsiolis <tsiolis.g@gmail.com>

* Adjusting styling per PR feedback

* don't hide toasts on hover

---------

Co-authored-by: George Tsiolis <tsiolis.g@gmail.com>
  • Loading branch information
selfcontained and gtsiolis committed Apr 6, 2023
1 parent 2401a18 commit ee4a5a0
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 76 deletions.
99 changes: 99 additions & 0 deletions components/dashboard/src/components/toasts/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ id, message, duration = 5000, autoHide = true, onRemove }) => {
const elId = useId();
const hideTimeout = useRef<ReturnType<typeof setTimeout> | 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 (
<div
className={classNames(
"relative flex justify-between items-center",
"w-full md:w-96 max-w-full",
"p-4 md:rounded-md",
"bg-gray-800 dark:bg-gray-100",
"text-white dark:text-gray-800",
"transition-transform animate-toast-in-right",
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
role="alert"
aria-labelledby={elId}
>
<p className="text-white dark:text-gray-800" id={elId}>
{message}
</p>
<button
className={classNames(
"cursor-pointer p-2",
"bg-transparent hover:bg-transparent",
"text-white hover:text-gray-300 dark:text-gray-800 dark:hover:text-gray-600",
)}
onClick={handleRemove}
>
<svg version="1.1" width="10px" height="10px" viewBox="0 0 100 100">
<line x1="0" y1="0" x2="100" y2="100" stroke="currentColor" strokeWidth="20" />
<line x1="0" y1="100" x2="100" y2="0" stroke="currentColor" strokeWidth="20" />
</svg>
</button>
</div>
);
};
82 changes: 82 additions & 0 deletions components/dashboard/src/components/toasts/Toasts.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastEntry, "message"> & Partial<ToastEntry>);

const ToastContext = createContext<{
toast: (toast: ToastFnProps, opts?: Partial<ToastEntry>) => 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 (
<ToastContext.Provider value={ctxValue}>
{children}
<ToastsList toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
};

type ToastsListProps = {
toasts: ToastEntry[];
onRemove: (id: string) => void;
};
const ToastsList: FC<ToastsListProps> = memo(({ toasts, onRemove }) => {
return (
<Portal>
<div
className={classNames(
"fixed box-border space-y-2",
"w-full md:w-auto",
"bottom-0 md:bottom-2 right-0 md:right-2",
)}
tabIndex={-1}
role="region"
aria-label="Notifications"
>
{toasts.map((toast) => {
return <Toast key={toast.id} {...toast} onRemove={onRemove} />;
})}
</div>
</Portal>
);
});
33 changes: 33 additions & 0 deletions components/dashboard/src/components/toasts/reducer.ts
Original file line number Diff line number Diff line change
@@ -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;
};
36 changes: 36 additions & 0 deletions components/dashboard/src/data/current-user/update-mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>;

Expand All @@ -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 ?? "",
});
}
},
});
};
41 changes: 22 additions & 19 deletions components/dashboard/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -59,25 +60,27 @@ const bootApp = () => {
<GitpodErrorBoundary>
<GitpodQueryClientProvider>
<ConfettiContextProvider>
<UserContextProvider>
<AdminContextProvider>
<PaymentContextProvider>
<LicenseContextProvider>
<ProjectContextProvider>
<ThemeContextProvider>
<BrowserRouter>
<StartWorkspaceModalContextProvider>
<FeatureFlagContextProvider>
<App />
</FeatureFlagContextProvider>
</StartWorkspaceModalContextProvider>
</BrowserRouter>
</ThemeContextProvider>
</ProjectContextProvider>
</LicenseContextProvider>
</PaymentContextProvider>
</AdminContextProvider>
</UserContextProvider>
<ToastContextProvider>
<UserContextProvider>
<AdminContextProvider>
<PaymentContextProvider>
<LicenseContextProvider>
<ProjectContextProvider>
<ThemeContextProvider>
<BrowserRouter>
<StartWorkspaceModalContextProvider>
<FeatureFlagContextProvider>
<App />
</FeatureFlagContextProvider>
</StartWorkspaceModalContextProvider>
</BrowserRouter>
</ThemeContextProvider>
</ProjectContextProvider>
</LicenseContextProvider>
</PaymentContextProvider>
</AdminContextProvider>
</UserContextProvider>
</ToastContextProvider>
</ConfettiContextProvider>
</GitpodQueryClientProvider>
</GitpodErrorBoundary>
Expand Down
Loading

0 comments on commit ee4a5a0

Please sign in to comment.