fix: I should be able to use "enter" key to create profile (#4978)

## Context
Fixes #4808 

TL;DR
Introducing pure stateless modal component ("UI modal") for our auth
modal not to have default hotkeyScope overriding our create-profile
hotkeyScope
+ we dont want the shortcut to be available for all the modal content, only for the input that should not be using a hotkeyscope, so we are using onKeyDown for the specific issue on create profile.

Explanation
create-profile hotkey scope is set by PageChangeEffect; CreateProfile
component adds enter key shortcut; but this scope is overwritten by the
default scope by the Modal component that expects a hotkeyScope to reset
to (and defaults to the default hotkeyScope if none indicated).
In the auth flow we were using that Modal component to give a modal look
to the flow but it is not a modal per say, it's a set of pages contained
within a modal look.
By creating this UI component we are escaping that hotkeyScope
overriding that does not make sense in our context.

## How was it tested
Locally
Storybook
This commit is contained in:
Marie 2024-04-17 10:45:02 +02:00 committed by GitHub
parent 5ecc4ad378
commit 17422b7690
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 244 additions and 202 deletions

View File

@ -1,9 +1,9 @@
import React from 'react';
import styled from '@emotion/styled';
import { Modal as UIModal } from '@/ui/layout/modal/components/Modal';
import { ModalLayout } from '@/ui/layout/modal/components/ModalLayout';
const StyledContent = styled(UIModal.Content)`
const StyledContent = styled(ModalLayout.Content)`
align-items: center;
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
`;
@ -11,7 +11,7 @@ const StyledContent = styled(UIModal.Content)`
type AuthModalProps = { children: React.ReactNode };
export const AuthModal = ({ children }: AuthModalProps) => (
<UIModal isOpen={true} padding={'none'}>
<ModalLayout padding={'none'}>
<StyledContent>{children}</StyledContent>
</UIModal>
</ModalLayout>
);

View File

@ -164,9 +164,9 @@ export const SignInUpForm = () => {
}
}}
error={showErrors ? error?.message : undefined}
onKeyDown={handleKeyDown}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}
@ -198,10 +198,10 @@ export const SignInUpForm = () => {
placeholder="Password"
onBlur={onBlur}
onChange={onChange}
onKeyDown={handleKeyDown}
error={showErrors ? error?.message : undefined}
fullWidth
disableHotkeys
onKeyDown={handleKeyDown}
/>
</StyledInputContainer>
)}

View File

@ -5,9 +5,7 @@ import { useParams } from 'react-router-dom';
import { useNavigateAfterSignInUp } from '@/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts';
import { Form } from '@/auth/sign-in-up/hooks/useSignInUpForm.ts';
import { AppPath } from '@/types/AppPath';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
import { useAuth } from '../../hooks/useAuth';
@ -118,31 +116,6 @@ export const useSignInUp = (form: UseFormReturn<Form>) => {
],
);
useScopedHotkeys(
'enter',
() => {
if (signInUpStep === SignInUpStep.Init) {
continueWithEmail();
}
if (signInUpStep === SignInUpStep.Email) {
continueWithCredentials();
}
if (signInUpStep === SignInUpStep.Password) {
form.handleSubmit(submitCredentials)();
}
},
PageHotkeyScope.SignInUp,
[
continueWithEmail,
signInUpStep,
continueWithCredentials,
form,
submitCredentials,
],
);
return {
isInviteMode,
signInUpStep,

View File

@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum';
import {
ModalLayout,
ModalLayoutProps,
} from '@/ui/layout/modal/components/ModalLayout';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
@ -12,135 +14,11 @@ import {
import { ModalHotkeyScope } from './types/ModalHotkeyScope';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalHeader = ({ children, className }: ModalHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & {
className?: string;
};
const ModalContent = ({ children, className }: ModalContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalFooter = ({ children, className }: ModalFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
type ModalProps = React.PropsWithChildren & {
type ModalProps = ModalLayoutProps & {
isOpen?: boolean;
onClose?: () => void;
hotkeyScope?: ModalHotkeyScope;
onClose?: () => void;
onEnter?: () => void;
size?: ModalSize;
padding?: ModalPadding;
className?: string;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
@ -153,14 +31,6 @@ export const Modal = ({
padding = 'medium',
className,
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.comparePixels,
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
@ -196,30 +66,28 @@ export const Modal = ({
setHotkeyScopeAndMemorizePreviousScope,
]);
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.comparePixels,
});
return isOpen ? (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
className={className}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
<ModalLayout
className={className}
modalRef={modalRef}
size={size}
padding={padding}
>
{children}
</ModalLayout>
) : (
<></>
);
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
Modal.Header = ModalLayout.Header;
Modal.Content = ModalLayout.Content;
Modal.Footer = ModalLayout.Footer;

View File

@ -0,0 +1,168 @@
import React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalLayoutHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutHeader = ({ children, className }: ModalLayoutHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalLayoutContentProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutContent = ({
children,
className,
}: ModalLayoutContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalLayoutFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutFooter = ({ children, className }: ModalLayoutFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
export type ModalLayoutProps = React.PropsWithChildren & {
size?: ModalSize;
padding?: ModalPadding;
className?: string;
modalRef?: React.RefObject<HTMLElement>;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
// This component should be used over Modal when seeking a modal feel without modal state (hotkeyScope etc)
export const ModalLayout = ({
children,
size = 'medium',
padding = 'medium',
modalRef,
className,
}: ModalLayoutProps) => {
return (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
className={className}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
);
};
ModalLayout.Header = ModalLayoutHeader;
ModalLayout.Content = ModalLayoutContent;
ModalLayout.Footer = ModalLayoutFooter;

View File

@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { ModalLayout } from '@/ui/layout/modal/components/ModalLayout';
const meta: Meta<typeof ModalLayout> = {
title: 'UI/Layout/Modal/ModalLayout',
component: ModalLayout,
};
export default meta;
type Story = StoryObj<typeof ModalLayout>;
export const Default: Story = {
args: {
size: 'medium',
padding: 'medium',
children: (
<>
<ModalLayout.Header>Stay in touch</ModalLayout.Header>
<ModalLayout.Content>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</ModalLayout.Content>
<ModalLayout.Footer>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</ModalLayout.Footer>
</>
),
},
decorators: [ComponentDecorator],
argTypes: {
children: { control: false },
},
};

View File

@ -3,7 +3,6 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { z } from 'zod';
import { SubTitle } from '@/auth/components/SubTitle';
@ -14,12 +13,10 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { H2Title } from '@/ui/display/typography/components/H2Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { TextInput } from '@/ui/input/components/TextInput';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
const StyledContentContainer = styled.div`
@ -126,19 +123,17 @@ export const CreateProfile = () => {
],
);
useScopedHotkeys(
Key.Enter,
() => {
onSubmit(getValues());
},
PageHotkeyScope.CreateProfile,
[onSubmit],
);
if (onboardingStatus !== OnboardingStatus.OngoingProfileCreation) {
return null;
}
const onNameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
onSubmit(getValues());
}
};
return (
<>
<Title withMarginTop={false}>Create profile</Title>
@ -171,6 +166,7 @@ export const CreateProfile = () => {
placeholder="Tim"
error={error?.message}
fullWidth
onKeyDown={onNameInputKeyDown}
disableHotkeys
/>
)}
@ -190,6 +186,7 @@ export const CreateProfile = () => {
placeholder="Cook"
error={error?.message}
fullWidth
onKeyDown={onNameInputKeyDown}
disableHotkeys
/>
)}