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
This commit is contained in:
@ -11,6 +11,7 @@ import '@emotion/react';
|
|||||||
|
|
||||||
import { ApolloProvider } from './providers/apollo/ApolloProvider';
|
import { ApolloProvider } from './providers/apollo/ApolloProvider';
|
||||||
import { ClientConfigProvider } from './providers/client-config/ClientConfigProvider';
|
import { ClientConfigProvider } from './providers/client-config/ClientConfigProvider';
|
||||||
|
import { SnackBarProvider } from './providers/snack-bar/SnackBarProvider';
|
||||||
import { AppThemeProvider } from './providers/theme/AppThemeProvider';
|
import { AppThemeProvider } from './providers/theme/AppThemeProvider';
|
||||||
import { UserProvider } from './providers/user/UserProvider';
|
import { UserProvider } from './providers/user/UserProvider';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
@ -29,11 +30,15 @@ root.render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<ClientConfigProvider>
|
<SnackBarProvider>
|
||||||
<HotkeysProvider initiallyActiveScopes={INITIAL_HOTKEYS_SCOPES}>
|
<ClientConfigProvider>
|
||||||
<App />
|
<HotkeysProvider
|
||||||
</HotkeysProvider>
|
initiallyActiveScopes={INITIAL_HOTKEYS_SCOPES}
|
||||||
</ClientConfigProvider>
|
>
|
||||||
|
<App />
|
||||||
|
</HotkeysProvider>
|
||||||
|
</ClientConfigProvider>
|
||||||
|
</SnackBarProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
|
|||||||
182
front/src/modules/snack-bar/components/SnackBar.tsx
Normal file
182
front/src/modules/snack-bar/components/SnackBar.tsx
Normal file
@ -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<Pick<SnackbarProps, 'variant'>>`
|
||||||
|
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<Pick<SnackbarProps, 'variant'>>`
|
||||||
|
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<ProgressBarControls | null>(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 (
|
||||||
|
<IconAlertTriangle aria-label="Error" size={theme.icon.size.md} />
|
||||||
|
);
|
||||||
|
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 (
|
||||||
|
<StyledMotionContainer
|
||||||
|
aria-live={role === 'alert' ? 'assertive' : 'polite'}
|
||||||
|
role={role}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
variant={variant}
|
||||||
|
{...rootProps}
|
||||||
|
>
|
||||||
|
<ProgressBarContainer>
|
||||||
|
<ProgressBar
|
||||||
|
ref={progressBarRef}
|
||||||
|
barHeight={5}
|
||||||
|
barColor={rgba(theme.color.gray0, 0.3)}
|
||||||
|
duration={duration}
|
||||||
|
/>
|
||||||
|
</ProgressBarContainer>
|
||||||
|
{icon && <StyledIconContainer>{icon}</StyledIconContainer>}
|
||||||
|
{children ? children : message}
|
||||||
|
{allowDismiss && (
|
||||||
|
<CloseButton variant={variant} onClick={closeSnackbar}>
|
||||||
|
<IconX aria-label="Close" size={theme.icon.size.md} />
|
||||||
|
</CloseButton>
|
||||||
|
)}
|
||||||
|
</StyledMotionContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
front/src/modules/snack-bar/hooks/usePausableTimeout.ts
Normal file
50
front/src/modules/snack-bar/hooks/usePausableTimeout.ts
Normal file
@ -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<number>(delay);
|
||||||
|
const startTime = useRef<number>(Date.now());
|
||||||
|
const timeoutId = useRef<ReturnType<typeof setTimeout> | 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 };
|
||||||
|
}
|
||||||
24
front/src/modules/snack-bar/hooks/useSnackBar.ts
Normal file
24
front/src/modules/snack-bar/hooks/useSnackBar.ts
Normal file
@ -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<SnackBarOptions, 'message' | 'id'>,
|
||||||
|
) => {
|
||||||
|
setSnackBarQueue({
|
||||||
|
id: uuidv4(),
|
||||||
|
message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { enqueueSnackBar };
|
||||||
|
}
|
||||||
39
front/src/modules/snack-bar/states/snackBarState.ts
Normal file
39
front/src/modules/snack-bar/states/snackBarState.ts
Normal file
@ -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<SnackBarState>({
|
||||||
|
key: 'snackBarState',
|
||||||
|
default: {
|
||||||
|
maxQueue: 3,
|
||||||
|
queue: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const snackBarSetQueueState = selector<SnackBarOptions | null>({
|
||||||
|
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[],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
101
front/src/modules/ui/components/progress-bar/ProgressBar.tsx
Normal file
101
front/src/modules/ui/components/progress-bar/ProgressBar.tsx
Normal file
@ -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<Pick<ProgressBarProps, 'barHeight'>>`
|
||||||
|
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<any>;
|
||||||
|
pause: () => Promise<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProgressBar = forwardRef<ProgressBarControls, ProgressBarProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
duration = 3,
|
||||||
|
delay = 0,
|
||||||
|
easing = 'easeInOut',
|
||||||
|
barHeight = 24,
|
||||||
|
barColor,
|
||||||
|
autoStart = true,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const controls = useAnimation();
|
||||||
|
const startTimestamp = useRef<number>(0);
|
||||||
|
const remainingTime = useRef<number>(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 (
|
||||||
|
<Bar barHeight={barHeight}>
|
||||||
|
<BarFilling
|
||||||
|
style={{
|
||||||
|
originX: 0,
|
||||||
|
// Seems like custom props are not well handled by react when used with framer-motion and emotion styled
|
||||||
|
backgroundColor: barColor ?? theme.color.gray80,
|
||||||
|
}}
|
||||||
|
initial={{ scaleX: 1 }}
|
||||||
|
animate={controls}
|
||||||
|
exit={{ scaleX: 0 }}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -38,3 +38,5 @@ export { IconTimelineEvent } from '@tabler/icons-react';
|
|||||||
export { IconAlertCircle } from '@tabler/icons-react';
|
export { IconAlertCircle } from '@tabler/icons-react';
|
||||||
export { IconEye } from '@tabler/icons-react';
|
export { IconEye } from '@tabler/icons-react';
|
||||||
export { IconEyeOff } from '@tabler/icons-react';
|
export { IconEyeOff } from '@tabler/icons-react';
|
||||||
|
export { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
|
export { IconCopy } from '@tabler/icons-react';
|
||||||
|
|||||||
@ -13,6 +13,20 @@ const common = {
|
|||||||
icon: icon,
|
icon: icon,
|
||||||
text: text,
|
text: text,
|
||||||
blur: blur,
|
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`,
|
spacing: (multiplicator: number) => `${multiplicator * 4}px`,
|
||||||
table: {
|
table: {
|
||||||
horizontalCellMargin: '8px',
|
horizontalCellMargin: '8px',
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
|
||||||
import { Button } from '@/ui/components/buttons/Button';
|
import { Button } from '@/ui/components/buttons/Button';
|
||||||
import { TextInput } from '@/ui/components/inputs/TextInput';
|
import { TextInput } from '@/ui/components/inputs/TextInput';
|
||||||
import { IconCheck, IconLink } from '@/ui/icons';
|
import { IconCopy, IconLink } from '@/ui/icons';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -23,24 +23,24 @@ type OwnProps = {
|
|||||||
|
|
||||||
export function WorkspaceInviteLink({ inviteLink }: OwnProps) {
|
export function WorkspaceInviteLink({ inviteLink }: OwnProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledLinkContainer>
|
<StyledLinkContainer>
|
||||||
<TextInput value={inviteLink} disabled fullWidth />
|
<TextInput value={inviteLink} disabled fullWidth />
|
||||||
</StyledLinkContainer>
|
</StyledLinkContainer>
|
||||||
<Button
|
<Button
|
||||||
icon={
|
icon={<IconLink size={theme.icon.size.md} />}
|
||||||
isCopied ? (
|
|
||||||
<IconCheck size={theme.icon.size.md} />
|
|
||||||
) : (
|
|
||||||
<IconLink size={theme.icon.size.md} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
variant="primary"
|
variant="primary"
|
||||||
title={isCopied ? 'Copied' : 'Copy link'}
|
title="Copy link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCopied(true);
|
enqueueSnackBar('Link copied to clipboard', {
|
||||||
|
variant: 'success',
|
||||||
|
icon: <IconCopy size={theme.icon.size.md} />,
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
navigator.clipboard.writeText(inviteLink);
|
navigator.clipboard.writeText(inviteLink);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
|
|||||||
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
||||||
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
||||||
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
|
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
|
||||||
|
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
|
||||||
import { MainButton } from '@/ui/components/buttons/MainButton';
|
import { MainButton } from '@/ui/components/buttons/MainButton';
|
||||||
import { TextInput } from '@/ui/components/inputs/TextInput';
|
import { TextInput } from '@/ui/components/inputs/TextInput';
|
||||||
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
||||||
@ -47,10 +48,6 @@ const StyledComboInputContainer = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledErrorContainer = styled.div`
|
|
||||||
color: ${({ theme }) => theme.color.red};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const validationSchema = Yup.object()
|
const validationSchema = Yup.object()
|
||||||
.shape({
|
.shape({
|
||||||
firstName: Yup.string().required('First name can not be empty'),
|
firstName: Yup.string().required('First name can not be empty'),
|
||||||
@ -64,6 +61,8 @@ export function CreateProfile() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onboardingStatus = useOnboardingStatus();
|
const onboardingStatus = useOnboardingStatus();
|
||||||
|
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const [currentUser] = useRecoilState(currentUserState);
|
const [currentUser] = useRecoilState(currentUserState);
|
||||||
|
|
||||||
const [updateUser] = useUpdateUserMutation();
|
const [updateUser] = useUpdateUserMutation();
|
||||||
@ -73,7 +72,6 @@ export function CreateProfile() {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isValid, isSubmitting },
|
formState: { isValid, isSubmitting },
|
||||||
setError,
|
|
||||||
getValues,
|
getValues,
|
||||||
} = useForm<Form>({
|
} = useForm<Form>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@ -118,10 +116,12 @@ export function CreateProfile() {
|
|||||||
|
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError('root', { message: error?.message });
|
enqueueSnackBar(error?.message, {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentUser?.id, navigate, setError, updateUser],
|
[currentUser?.id, enqueueSnackBar, navigate, updateUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
useScopedHotkeys(
|
useScopedHotkeys(
|
||||||
@ -202,14 +202,6 @@ export function CreateProfile() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
{/* Will be replaced by error snack bar */}
|
|
||||||
<Controller
|
|
||||||
name="firstName"
|
|
||||||
control={control}
|
|
||||||
render={({ formState: { errors } }) => (
|
|
||||||
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
|
|||||||
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
||||||
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
||||||
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
|
import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader';
|
||||||
|
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
|
||||||
import { MainButton } from '@/ui/components/buttons/MainButton';
|
import { MainButton } from '@/ui/components/buttons/MainButton';
|
||||||
import { TextInput } from '@/ui/components/inputs/TextInput';
|
import { TextInput } from '@/ui/components/inputs/TextInput';
|
||||||
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
||||||
@ -36,10 +37,6 @@ const StyledButtonContainer = styled.div`
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledErrorContainer = styled.div`
|
|
||||||
color: ${({ theme }) => theme.color.red};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const validationSchema = Yup.object()
|
const validationSchema = Yup.object()
|
||||||
.shape({
|
.shape({
|
||||||
name: Yup.string().required('Name can not be empty'),
|
name: Yup.string().required('Name can not be empty'),
|
||||||
@ -52,6 +49,8 @@ export function CreateWorkspace() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onboardingStatus = useOnboardingStatus();
|
const onboardingStatus = useOnboardingStatus();
|
||||||
|
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
const [updateWorkspace] = useUpdateWorkspaceMutation();
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
@ -59,7 +58,6 @@ export function CreateWorkspace() {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isValid, isSubmitting },
|
formState: { isValid, isSubmitting },
|
||||||
setError,
|
|
||||||
getValues,
|
getValues,
|
||||||
} = useForm<Form>({
|
} = useForm<Form>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
@ -90,10 +88,12 @@ export function CreateWorkspace() {
|
|||||||
|
|
||||||
navigate('/auth/create/profile');
|
navigate('/auth/create/profile');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError('root', { message: error?.message });
|
enqueueSnackBar(error?.message, {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, setError, updateWorkspace],
|
[enqueueSnackBar, navigate, updateWorkspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
useScopedHotkeys(
|
useScopedHotkeys(
|
||||||
@ -156,14 +156,6 @@ export function CreateWorkspace() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
{/* Will be replaced by error snack bar */}
|
|
||||||
<Controller
|
|
||||||
name="name"
|
|
||||||
control={control}
|
|
||||||
render={({ formState: { errors } }) => (
|
|
||||||
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { PASSWORD_REGEX } from '@/auth/utils/passwordRegex';
|
|||||||
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
|
import { isDemoModeState } from '@/client-config/states/isDemoModeState';
|
||||||
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
||||||
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
||||||
|
import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
|
||||||
import { MainButton } from '@/ui/components/buttons/MainButton';
|
import { MainButton } from '@/ui/components/buttons/MainButton';
|
||||||
import { TextInput } from '@/ui/components/inputs/TextInput';
|
import { TextInput } from '@/ui/components/inputs/TextInput';
|
||||||
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
|
||||||
@ -47,10 +48,6 @@ const StyledButtonContainer = styled.div`
|
|||||||
width: 200px;
|
width: 200px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledErrorContainer = styled.div`
|
|
||||||
color: ${({ theme }) => theme.color.red};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const validationSchema = Yup.object()
|
const validationSchema = Yup.object()
|
||||||
.shape({
|
.shape({
|
||||||
exist: Yup.boolean().required(),
|
exist: Yup.boolean().required(),
|
||||||
@ -69,6 +66,8 @@ type Form = Yup.InferType<typeof validationSchema>;
|
|||||||
export function PasswordLogin() {
|
export function PasswordLogin() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
|
|
||||||
const [isDemoMode] = useRecoilState(isDemoModeState);
|
const [isDemoMode] = useRecoilState(isDemoModeState);
|
||||||
const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState);
|
const [authFlowUserEmail] = useRecoilState(authFlowUserEmailState);
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
@ -88,7 +87,6 @@ export function PasswordLogin() {
|
|||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
setError,
|
|
||||||
watch,
|
watch,
|
||||||
getValues,
|
getValues,
|
||||||
} = useForm<Form>({
|
} = useForm<Form>({
|
||||||
@ -114,16 +112,19 @@ export function PasswordLogin() {
|
|||||||
}
|
}
|
||||||
navigate('/auth/create/workspace');
|
navigate('/auth/create/workspace');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('root', { message: err?.message });
|
console.log('err', err);
|
||||||
|
enqueueSnackBar(err?.message, {
|
||||||
|
variant: 'error',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
login,
|
checkUserExistsData?.checkUserExists.exists,
|
||||||
navigate,
|
navigate,
|
||||||
setError,
|
login,
|
||||||
signUp,
|
signUp,
|
||||||
workspaceInviteHash,
|
workspaceInviteHash,
|
||||||
checkUserExistsData,
|
enqueueSnackBar,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
useScopedHotkeys(
|
useScopedHotkeys(
|
||||||
@ -200,14 +201,6 @@ export function PasswordLogin() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</StyledButtonContainer>
|
</StyledButtonContainer>
|
||||||
{/* Will be replaced by error snack bar */}
|
|
||||||
<Controller
|
|
||||||
name="exist"
|
|
||||||
control={control}
|
|
||||||
render={({ formState: { errors } }) => (
|
|
||||||
<StyledErrorContainer>{errors?.root?.message}</StyledErrorContainer>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</StyledForm>
|
</StyledForm>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
91
front/src/providers/snack-bar/SnackBarProvider.tsx
Normal file
91
front/src/providers/snack-bar/SnackBarProvider.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { motion, useReducedMotion } from 'framer-motion';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
||||||
|
import { SnackBar } from '@/snack-bar/components/SnackBar';
|
||||||
|
|
||||||
|
import { snackBarInternalState } from '../../modules/snack-bar/states/snackBarState';
|
||||||
|
|
||||||
|
const SnackBarContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 99999999;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SnackBarMotionContainer = styled(motion.div)`
|
||||||
|
margin-right: ${({ theme }) => theme.spacing(3)};
|
||||||
|
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -40,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -40,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const reducedVariants = {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -40,
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -40,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SnackBarProvider({ children }: React.PropsWithChildren) {
|
||||||
|
const reducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const [snackBarState, setSnackBarState] = useRecoilState(
|
||||||
|
snackBarInternalState,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle snackbar close event
|
||||||
|
const handleSnackBarClose = (id: string) => {
|
||||||
|
setSnackBarState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<SnackBarContainer>
|
||||||
|
{snackBarState.queue.map((snackBar) => (
|
||||||
|
<SnackBarMotionContainer
|
||||||
|
key={snackBar.id}
|
||||||
|
variants={reducedMotion ? reducedVariants : variants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
layout
|
||||||
|
>
|
||||||
|
<SnackBar
|
||||||
|
{...snackBar}
|
||||||
|
onClose={() => handleSnackBarClose(snackBar.id)}
|
||||||
|
/>
|
||||||
|
</SnackBarMotionContainer>
|
||||||
|
))}
|
||||||
|
</SnackBarContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user