From 03364330d18ce04a8d68dbc27db9377b3ba6d614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20M?= Date: Fri, 14 Jul 2023 06:27:09 +0200 Subject: [PATCH] feat: snack-bar component (#626) * feat: SnackBarProvider and queuing * feat: use snack bar on onboarding errors * feat: workspace copy use snackBar * fix: remove magic number --- front/src/index.tsx | 15 +- .../modules/snack-bar/components/SnackBar.tsx | 182 ++++++++++++++++++ .../snack-bar/hooks/usePausableTimeout.ts | 50 +++++ .../modules/snack-bar/hooks/useSnackBar.ts | 24 +++ .../modules/snack-bar/states/snackBarState.ts | 39 ++++ .../components/progress-bar/ProgressBar.tsx | 101 ++++++++++ front/src/modules/ui/icons/index.ts | 2 + front/src/modules/ui/themes/themes.ts | 14 ++ .../components/WorkspaceInviteLink.tsx | 24 +-- front/src/pages/auth/CreateProfile.tsx | 22 +-- front/src/pages/auth/CreateWorkspace.tsx | 22 +-- front/src/pages/auth/PasswordLogin.tsx | 27 +-- .../providers/snack-bar/SnackBarProvider.tsx | 91 +++++++++ 13 files changed, 549 insertions(+), 64 deletions(-) create mode 100644 front/src/modules/snack-bar/components/SnackBar.tsx create mode 100644 front/src/modules/snack-bar/hooks/usePausableTimeout.ts create mode 100644 front/src/modules/snack-bar/hooks/useSnackBar.ts create mode 100644 front/src/modules/snack-bar/states/snackBarState.ts create mode 100644 front/src/modules/ui/components/progress-bar/ProgressBar.tsx create mode 100644 front/src/providers/snack-bar/SnackBarProvider.tsx diff --git a/front/src/index.tsx b/front/src/index.tsx index ed254ed2c..261b9e2f6 100644 --- a/front/src/index.tsx +++ b/front/src/index.tsx @@ -11,6 +11,7 @@ import '@emotion/react'; import { ApolloProvider } from './providers/apollo/ApolloProvider'; import { ClientConfigProvider } from './providers/client-config/ClientConfigProvider'; +import { SnackBarProvider } from './providers/snack-bar/SnackBarProvider'; import { AppThemeProvider } from './providers/theme/AppThemeProvider'; import { UserProvider } from './providers/user/UserProvider'; import { App } from './App'; @@ -29,11 +30,15 @@ root.render( - - - - - + + + + + + + diff --git a/front/src/modules/snack-bar/components/SnackBar.tsx b/front/src/modules/snack-bar/components/SnackBar.tsx new file mode 100644 index 000000000..a685e6d81 --- /dev/null +++ b/front/src/modules/snack-bar/components/SnackBar.tsx @@ -0,0 +1,182 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { usePausableTimeout } from '@/snack-bar/hooks/usePausableTimeout'; +import { IconAlertTriangle, IconX } from '@/ui/icons'; +import { rgba } from '@/ui/themes/colors'; + +import { + ProgressBar, + ProgressBarControls, +} from '../../ui/components/progress-bar/ProgressBar'; + +const StyledMotionContainer = styled.div>` + align-items: center; + background-color: ${({ theme, variant }) => { + switch (variant) { + case 'error': + return theme.snackBar.error.background; + case 'success': + return theme.snackBar.success.background; + case 'info': + default: + return theme.color.gray80; + } + }}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-shadow: ${({ theme }) => theme.boxShadow.strong}; + color: ${({ theme, variant }) => { + switch (variant) { + case 'error': + return theme.snackBar.error.color; + case 'success': + return theme.snackBar.success.color; + case 'info': + default: + return theme.color.gray0; + } + }}; + cursor: pointer; + display: flex; + height: 40px; + overflow: hidden; + padding: ${({ theme }) => theme.spacing(2)}; + pointer-events: auto; + position: relative; +`; + +const StyledIconContainer = styled.div` + display: flex; + margin-right: ${({ theme }) => theme.spacing(2)}; +`; + +const ProgressBarContainer = styled.div` + height: 5px; + left: 0; + position: absolute; + right: 0; + top: 0; +`; + +const CloseButton = styled.button>` + align-items: center; + background-color: transparent; + border: none; + border-radius: 12px; + color: ${({ theme, variant }) => { + switch (variant) { + case 'error': + return theme.color.red20; + case 'success': + return theme.color.turquoise20; + case 'info': + default: + return theme.color.gray0; + } + }}; + cursor: pointer; + display: flex; + height: 24px; + justify-content: center; + margin-left: ${({ theme }) => theme.spacing(6)}; + padding-left: ${({ theme }) => theme.spacing(1)}; + padding-right: ${({ theme }) => theme.spacing(1)}; + width: 24px; + + &:hover { + background-color: ${({ theme }) => rgba(theme.color.gray0, 0.1)}; + } +`; + +export type SnackbarVariant = 'info' | 'error' | 'success'; + +export interface SnackbarProps extends React.ComponentPropsWithoutRef<'div'> { + role?: 'alert' | 'status'; + icon?: React.ReactNode; + message?: string; + allowDismiss?: boolean; + duration?: number; + variant?: SnackbarVariant; + children?: React.ReactNode; + onClose?: () => void; +} + +export function SnackBar({ + role = 'status', + icon: iconComponent, + message, + allowDismiss = true, + duration = 6000, + variant = 'info', + children, + onClose, + ...rootProps +}: SnackbarProps) { + const theme = useTheme(); + + const progressBarRef = useRef(null); + + const closeSnackbar = useCallback(() => { + onClose && onClose(); + }, [onClose]); + + const { pauseTimeout, resumeTimeout } = usePausableTimeout( + closeSnackbar, + duration, + ); + + const icon = useMemo(() => { + if (iconComponent) { + return iconComponent; + } + + switch (variant) { + case 'error': + return ( + + ); + case 'success': + case 'info': + default: + return null; + } + }, [iconComponent, theme.icon.size.md, variant]); + + const onMouseEnter = () => { + progressBarRef.current?.pause(); + pauseTimeout(); + }; + + const onMouseLeave = () => { + progressBarRef.current?.start(); + resumeTimeout(); + }; + + return ( + + + + + {icon && {icon}} + {children ? children : message} + {allowDismiss && ( + + + + )} + + ); +} diff --git a/front/src/modules/snack-bar/hooks/usePausableTimeout.ts b/front/src/modules/snack-bar/hooks/usePausableTimeout.ts new file mode 100644 index 000000000..1429bfe45 --- /dev/null +++ b/front/src/modules/snack-bar/hooks/usePausableTimeout.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export function usePausableTimeout(callback: () => void, delay: number) { + const savedCallback = useRef<() => void>(callback); + const remainingTime = useRef(delay); + const startTime = useRef(Date.now()); + const timeoutId = useRef | null>(null); + + const tick = () => { + if (savedCallback.current) { + savedCallback.current(); + } + }; + + const startTimeout = useCallback(() => { + startTime.current = Date.now(); + timeoutId.current = setTimeout(tick, remainingTime.current); + }, []); + + // Remember the latest callback + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the timeout loop + useEffect(() => { + if (delay !== null) { + startTimeout(); + return () => { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + }; + } + }, [delay, startTimeout]); + + const pauseTimeout = () => { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + const elapsedTime = Date.now() - startTime.current; + remainingTime.current = remainingTime.current - elapsedTime; + }; + + const resumeTimeout = () => { + startTimeout(); + }; + + return { pauseTimeout, resumeTimeout }; +} diff --git a/front/src/modules/snack-bar/hooks/useSnackBar.ts b/front/src/modules/snack-bar/hooks/useSnackBar.ts new file mode 100644 index 000000000..2db7474ab --- /dev/null +++ b/front/src/modules/snack-bar/hooks/useSnackBar.ts @@ -0,0 +1,24 @@ +import { useSetRecoilState } from 'recoil'; +import { v4 as uuidv4 } from 'uuid'; + +import { + SnackBarOptions, + snackBarSetQueueState, +} from '../states/snackBarState'; + +export function useSnackBar() { + const setSnackBarQueue = useSetRecoilState(snackBarSetQueueState); + + const enqueueSnackBar = ( + message: string, + options?: Omit, + ) => { + setSnackBarQueue({ + id: uuidv4(), + message, + ...options, + }); + }; + + return { enqueueSnackBar }; +} diff --git a/front/src/modules/snack-bar/states/snackBarState.ts b/front/src/modules/snack-bar/states/snackBarState.ts new file mode 100644 index 000000000..c6626d0d5 --- /dev/null +++ b/front/src/modules/snack-bar/states/snackBarState.ts @@ -0,0 +1,39 @@ +import { atom, selector } from 'recoil'; + +import { SnackbarProps } from '@/snack-bar/components/SnackBar'; + +export type SnackBarOptions = SnackbarProps & { + id: string; +}; + +export type SnackBarState = { + maxQueue: number; + queue: SnackBarOptions[]; +}; + +export const snackBarInternalState = atom({ + key: 'snackBarState', + default: { + maxQueue: 3, + queue: [], + }, +}); + +export const snackBarSetQueueState = selector({ + key: 'snackBarQueueState', + get: ({ get }) => null, // We don't care about getting the value + set: ({ set }, newValue) => + set(snackBarInternalState, (prev) => { + if (prev.queue.length >= prev.maxQueue) { + return { + ...prev, + queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[], + }; + } + + return { + ...prev, + queue: [...prev.queue, newValue] as SnackBarOptions[], + }; + }), +}); diff --git a/front/src/modules/ui/components/progress-bar/ProgressBar.tsx b/front/src/modules/ui/components/progress-bar/ProgressBar.tsx new file mode 100644 index 000000000..48118e16a --- /dev/null +++ b/front/src/modules/ui/components/progress-bar/ProgressBar.tsx @@ -0,0 +1,101 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, +} from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { AnimationControls, motion, useAnimation } from 'framer-motion'; + +const Bar = styled.div>` + height: ${({ barHeight }) => barHeight}px; + overflow: hidden; + width: 100%; +`; + +const BarFilling = styled(motion.div)` + height: 100%; + width: 100%; +`; + +export type ProgressBarProps = { + duration?: number; + delay?: number; + easing?: string; + barHeight?: number; + barColor?: string; + autoStart?: boolean; +}; + +export type ProgressBarControls = AnimationControls & { + start: () => Promise; + pause: () => Promise; +}; + +export const ProgressBar = forwardRef( + ( + { + duration = 3, + delay = 0, + easing = 'easeInOut', + barHeight = 24, + barColor, + autoStart = true, + }, + ref, + ) => { + const theme = useTheme(); + + const controls = useAnimation(); + const startTimestamp = useRef(0); + const remainingTime = useRef(duration); + + const start = useCallback(async () => { + startTimestamp.current = Date.now(); + return controls.start({ + scaleX: 0, + transition: { + duration: remainingTime.current / 1000, // convert ms to s for framer-motion + delay: delay / 1000, // likewise + ease: easing, + }, + }); + }, [controls, delay, easing]); + + useImperativeHandle(ref, () => ({ + ...controls, + start: async () => { + return start(); + }, + pause: async () => { + const elapsed = Date.now() - startTimestamp.current; + + remainingTime.current = remainingTime.current - elapsed; + return controls.stop(); + }, + })); + + useEffect(() => { + if (autoStart) { + start(); + } + }, [controls, delay, duration, easing, autoStart, start]); + + return ( + + + + ); + }, +); diff --git a/front/src/modules/ui/icons/index.ts b/front/src/modules/ui/icons/index.ts index c1feebb6c..072250a56 100644 --- a/front/src/modules/ui/icons/index.ts +++ b/front/src/modules/ui/icons/index.ts @@ -38,3 +38,5 @@ export { IconTimelineEvent } from '@tabler/icons-react'; export { IconAlertCircle } from '@tabler/icons-react'; export { IconEye } from '@tabler/icons-react'; export { IconEyeOff } from '@tabler/icons-react'; +export { IconAlertTriangle } from '@tabler/icons-react'; +export { IconCopy } from '@tabler/icons-react'; diff --git a/front/src/modules/ui/themes/themes.ts b/front/src/modules/ui/themes/themes.ts index 49fa50fd9..85bff0f12 100644 --- a/front/src/modules/ui/themes/themes.ts +++ b/front/src/modules/ui/themes/themes.ts @@ -13,6 +13,20 @@ const common = { icon: icon, text: text, blur: blur, + snackBar: { + success: { + background: '#16A26B', + color: '#D0F8E9', + }, + error: { + background: '#B43232', + color: '#FED8D8', + }, + info: { + background: color.gray80, + color: color.gray0, + }, + }, spacing: (multiplicator: number) => `${multiplicator * 4}px`, table: { horizontalCellMargin: '8px', diff --git a/front/src/modules/workspace/components/WorkspaceInviteLink.tsx b/front/src/modules/workspace/components/WorkspaceInviteLink.tsx index dc094b354..2d63e2c8f 100644 --- a/front/src/modules/workspace/components/WorkspaceInviteLink.tsx +++ b/front/src/modules/workspace/components/WorkspaceInviteLink.tsx @@ -1,10 +1,10 @@ -import { useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useSnackBar } from '@/snack-bar/hooks/useSnackBar'; import { Button } from '@/ui/components/buttons/Button'; import { TextInput } from '@/ui/components/inputs/TextInput'; -import { IconCheck, IconLink } from '@/ui/icons'; +import { IconCopy, IconLink } from '@/ui/icons'; const StyledContainer = styled.div` align-items: center; @@ -23,24 +23,24 @@ type OwnProps = { export function WorkspaceInviteLink({ inviteLink }: OwnProps) { const theme = useTheme(); - const [isCopied, setIsCopied] = useState(false); + + const { enqueueSnackBar } = useSnackBar(); + return (