Skip to content

Commit

Permalink
🪟 🎨 Auto-dismissing toast notifications (#7508)
Browse files Browse the repository at this point in the history
Co-authored-by: Tim Roes <tim@airbyte.io>
  • Loading branch information
josephkmh and timroes committed Jun 28, 2023
1 parent 09cec49 commit ad6e3f1
Show file tree
Hide file tree
Showing 13 changed files with 179 additions and 111 deletions.
73 changes: 38 additions & 35 deletions airbyte-webapp/src/components/NewJobItem/NewJobItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,43 +70,46 @@ export const NewJobItem: React.FC<NewJobItemProps> = ({ jobWithAttempts }) => {
text: <FormattedMessage id="jobHistory.logs.logDownloadPending" values={{ jobId: jobWithAttempts.job.id }} />,
id: notificationId,
});
fetchJobLogs()
.then(({ data }) => {
if (!data) {
throw new Error("No logs returned from server");
}
const file = new Blob(
[
data.attempts
.flatMap((info, index) => [
`>> ATTEMPT ${index + 1}/${data.attempts.length}\n`,
...info.logs.logLines,
`\n\n\n`,
])
.join("\n"),
],
{
type: FILE_TYPE_DOWNLOAD,
// Promise.all() with a timeout is used to ensure that the notification is shown to the user for at least 1 second
Promise.all([
fetchJobLogs()
.then(({ data }) => {
if (!data) {
throw new Error("No logs returned from server");
}
);
downloadFile(file, fileizeString(`${workspaceName}-logs-${jobWithAttempts.job.id}.txt`));
})
.catch((e) => {
trackError(e, { workspaceId, jobId: jobWithAttempts.job.id });
registerNotification({
type: "error",
text: formatMessage(
const file = new Blob(
[
data.attempts
.flatMap((info, index) => [
`>> ATTEMPT ${index + 1}/${data.attempts.length}\n`,
...info.logs.logLines,
`\n\n\n`,
])
.join("\n"),
],
{
id: "jobHistory.logs.logDownloadFailed",
},
{ jobId: jobWithAttempts.job.id }
),
id: `download-logs-error-${jobWithAttempts.job.id}`,
});
})
.finally(() => {
unregisterNotificationById(notificationId);
});
type: FILE_TYPE_DOWNLOAD,
}
);
downloadFile(file, fileizeString(`${workspaceName}-logs-${jobWithAttempts.job.id}.txt`));
})
.catch((e) => {
trackError(e, { workspaceId, jobId: jobWithAttempts.job.id });
registerNotification({
type: "error",
text: formatMessage(
{
id: "jobHistory.logs.logDownloadFailed",
},
{ jobId: jobWithAttempts.job.id }
),
id: `download-logs-error-${jobWithAttempts.job.id}`,
});
}),
new Promise((resolve) => setTimeout(resolve, 1000)),
]).finally(() => {
unregisterNotificationById(notificationId);
});
break;
case ContextMenuOptions.OpenLogsModal:
openModal({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1716,6 +1716,9 @@ exports[`CreateConnectionForm should render 1`] = `
</div>
</form>
</div>
<div
class="<removed-for-snapshot-test>"
/>
</div>
</body>
`;
Expand Down Expand Up @@ -1745,6 +1748,9 @@ exports[`CreateConnectionForm should render when loading 1`] = `
This should take less than a minute, but may take a few minutes on slow internet connections or data sources with a large amount of tables.
</div>
</div>
<div
class="<removed-for-snapshot-test>"
/>
</div>
</body>
`;
Expand Down Expand Up @@ -1821,6 +1827,9 @@ exports[`CreateConnectionForm should render with an error 1`] = `
</div>
</div>
</div>
<div
class="<removed-for-snapshot-test>"
/>
</div>
</body>
`;
9 changes: 6 additions & 3 deletions airbyte-webapp/src/components/ui/Message/Message.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ $message-icon-container-size: 32px;

@mixin type($name, $color, $background) {
&.#{$name} {
--message-color: #{$color};

background-color: $background;
border-color: $color;
border-color: var(--message-color);


.iconContainer {
background-color: $color;
background-color: var(--message-color);
}

.messageIcon {
color: $color;
color: var(--message-color);
}
}
}
Expand Down
55 changes: 32 additions & 23 deletions airbyte-webapp/src/components/ui/Toast/Toast.module.scss
Original file line number Diff line number Diff line change
@@ -1,33 +1,42 @@
@use "scss/colors";
@use "scss/variables";
@use "scss/z-indices";
@use "scss/mixins";

$toast-bottom-margin: 27px;

@keyframes slide-up-animations {
0% {
transform: translate(-50%, -100%);
bottom: -60px;
}

100% {
transform: translate(-50%, 0);
bottom: $toast-bottom-margin;
}
}

.toastContainer {
box-shadow: variables.$box-shadow-raised;
max-width: variables.$width-max-notification;
position: fixed;
bottom: $toast-bottom-margin;
margin-left: calc(variables.$width-size-menu / 2);
left: 50%;
transform: translate(-50%, 0);
z-index: z-indices.$notification;
border-radius: variables.$border-radius-md;
border-width: 1px;
border-style: solid;
animation: slide-up-animations 0.25s ease-out;
position: relative;
overflow: hidden;


&--timeout {
&::after {
content: " ";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;

// Use as a background color the primary color of the message as set via the Message component
background-color: var(--message-color);
animation: timeout-bar forwards 5s linear;
}

&:hover::after {
animation-play-state: paused;
}
}
}

@keyframes timeout-bar {
from {
width: 100%;
}

to {
width: 0;
}
}
17 changes: 14 additions & 3 deletions airbyte-webapp/src/components/ui/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import React from "react";
import styles from "./Toast.module.scss";
import { Message, MessageProps } from "../Message";

export type ToastProps = MessageProps;
export interface ToastProps extends MessageProps {
timeout?: boolean;
}

export const Toast: React.FC<ToastProps> = (props) => {
return <Message {...props} className={classNames(props.className, styles.toastContainer)} />;
export const Toast: React.FC<ToastProps> = ({ timeout, ...props }) => {
return (
<div onAnimationEnd={props.onClose}>
<Message
{...props}
className={classNames(props.className, styles.toastContainer, {
[styles["toastContainer--timeout"]]: timeout,
})}
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@use "scss/colors";
@use "scss/variables";
@use "scss/z-indices";

.notifications {
display: flex;
flex-direction: column;
gap: variables.$spacing-xl;
position: fixed;
bottom: variables.$spacing-xl;
width: 400px;
left: calc(50vw - 200px);
z-index: z-indices.$notification;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React, { useContext, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import React, { useContext, useMemo } from "react";

import { Toast } from "components/ui/Toast";

import useTypesafeReducer from "hooks/useTypesafeReducer";

import styles from "./NotificationService.module.scss";
import { actions, initialState, notificationServiceReducer } from "./reducer";
import { Notification, NotificationServiceApi, NotificationServiceState } from "./types";

Expand All @@ -25,29 +27,47 @@ export const NotificationService = React.memo(({ children }: { children: React.R
[]
);

const firstNotification = state.notifications && state.notifications.length ? state.notifications[0] : null;
const registerNotification = (notification: Notification) => {
addNotification({ ...notification, timeout: notification.timeout ?? notification.type !== "error" });
};

return (
<>
<notificationServiceContext.Provider value={notificationService}>{children}</notificationServiceContext.Provider>
{firstNotification ? (
// Show only first notification
<Toast
text={firstNotification.text}
type={firstNotification.type}
actionBtnText={firstNotification.actionBtnText}
onAction={firstNotification.onAction}
data-testid={`notification-${firstNotification.id}`}
onClose={
firstNotification.nonClosable
? undefined
: () => {
deleteNotificationById(firstNotification.id);
firstNotification.onClose?.();
}
}
/>
) : null}
<notificationServiceContext.Provider value={{ ...notificationService, addNotification: registerNotification }}>
{children}
</notificationServiceContext.Provider>
<motion.div className={styles.notifications}>
<AnimatePresence>
{state.notifications
.slice()
.reverse()
.map((notification) => {
return (
<motion.div
layout="position"
key={notification.id}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1, transition: { delay: 0.1 } }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ ease: "easeOut" }}
>
<Toast
text={notification.text}
type={notification.type}
timeout={notification.timeout}
actionBtnText={notification.actionBtnText}
onAction={notification.onAction}
data-testid={`notification-${notification.id}`}
onClose={() => {
deleteNotificationById(notification.id);
notification.onClose?.();
}}
/>
</motion.div>
);
})}
</AnimatePresence>
</motion.div>
</>
);
});
Expand All @@ -59,27 +79,12 @@ interface NotificationServiceHook {
unregisterNotificationById: (notificationId: string | number) => void;
}

export const useNotificationService: (notification?: Notification, dependencies?: []) => NotificationServiceHook = (
notification,
dependencies
) => {
export const useNotificationService = (): NotificationServiceHook => {
const notificationService = useContext(notificationServiceContext);
if (!notificationService) {
throw new Error("useNotificationService must be used within a NotificationService.");
}

useEffect(() => {
if (notification) {
notificationService.addNotification(notification);
}
return () => {
if (notification) {
notificationService.deleteNotificationById(notification.id);
}
};
// eslint-disable-next-line
}, [notification, notificationService, ...(dependencies || [])]);

return {
registerNotification: notificationService.addNotification,
unregisterNotificationById: notificationService.deleteNotificationById,
Expand Down
10 changes: 7 additions & 3 deletions airbyte-webapp/src/hooks/services/Notification/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ function removeNotification(notifications: Notification[], notificationId: strin
return notifications.filter((n) => n.id !== notificationId);
}

function findNotification(notifications: Notification[], notification: Notification): Notification | undefined {
return notifications.find((n) => n.id === notification.id);
function findNotification(notifications: Notification[], notificationId: string | number): Notification | undefined {
return notifications.find((n) => n.id === notificationId);
}

export const initialState: NotificationServiceState = {
Expand All @@ -24,7 +24,7 @@ export const initialState: NotificationServiceState = {

export const notificationServiceReducer = createReducer<NotificationServiceState, Actions>(initialState)
.handleAction(actions.addNotification, (state, action): NotificationServiceState => {
if (findNotification(state.notifications, action.payload)) {
if (findNotification(state.notifications, action.payload.id)) {
return state;
}

Expand All @@ -35,6 +35,10 @@ export const notificationServiceReducer = createReducer<NotificationServiceState
};
})
.handleAction(actions.deleteNotificationById, (state, action): NotificationServiceState => {
if (!findNotification(state.notifications, action.payload)) {
return state;
}

const notifications = removeNotification(state.notifications, action.payload);

return {
Expand Down
6 changes: 5 additions & 1 deletion airbyte-webapp/src/hooks/services/Notification/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { ToastProps } from "components/ui/Toast";

export interface Notification extends Pick<ToastProps, "type" | "onAction" | "onClose" | "actionBtnText" | "text"> {
id: string;
nonClosable?: boolean;
/**
* Whether this notification should time out automatically. If unspecified will be `true` except for `type: error` notifications,
* where it will be `false` if not specified.
*/
timeout?: boolean;
}

export interface NotificationServiceApi {
Expand Down
Loading

0 comments on commit ad6e3f1

Please sign in to comment.