Reafactor/UI input and displays (#1544)

* WIP

* Text field

* URL

* Finished PhoneInput

* Refactored input sub-folders

* Boolean

* Fix lint

* Fix lint

* Fix useOutsideClick

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-09-12 02:11:20 +02:00
committed by GitHub
parent 509ffddc57
commit a766c60aa5
90 changed files with 618 additions and 461 deletions

View File

@ -83,8 +83,6 @@ type OwnProps = {
isDisplayModeContentEmpty?: boolean;
isDisplayModeFixHeight?: boolean;
disableHoverEffect?: boolean;
onSubmit?: () => void;
onCancel?: () => void;
};
export function EditableField({
@ -99,8 +97,6 @@ export function EditableField({
displayModeContentOnly,
isDisplayModeFixHeight,
disableHoverEffect,
onSubmit,
onCancel,
}: OwnProps) {
const [isHovered, setIsHovered] = useState(false);
@ -115,10 +111,13 @@ export function EditableField({
const { isFieldInEditMode, openEditableField } = useEditableField();
function handleDisplayModeClick() {
openEditableField(customEditHotkeyScope);
if (!displayModeContentOnly) {
openEditableField(customEditHotkeyScope);
}
}
const showEditButton = !isFieldInEditMode && isHovered && useEditButton;
const showEditButton =
!isFieldInEditMode && isHovered && useEditButton && !displayModeContentOnly;
return (
<StyledEditableFieldBaseContainer
@ -137,10 +136,8 @@ export function EditableField({
</StyledLabelAndIconContainer>
<StyledValueContainer>
{isFieldInEditMode && !displayModeContentOnly ? (
<EditableFieldEditMode onSubmit={onSubmit} onCancel={onCancel}>
{editModeContent}
</EditableFieldEditMode>
{isFieldInEditMode ? (
<EditableFieldEditMode>{editModeContent}</EditableFieldEditMode>
) : (
<StyledClickableContainer onClick={handleDisplayModeClick}>
<EditableFieldDisplayMode

View File

@ -1,7 +1,6 @@
import { useRef } from 'react';
import styled from '@emotion/styled';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
import { overlayBackground } from '@/ui/theme/constants/effects';
const StyledEditableFieldEditModeContainer = styled.div<OwnProps>`
align-items: center;
@ -14,28 +13,28 @@ const StyledEditableFieldEditModeContainer = styled.div<OwnProps>`
z-index: 10;
`;
const StyledEditableFieldInput = styled.div`
align-items: center;
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
margin-left: -1px;
min-height: 32px;
width: inherit;
${overlayBackground}
z-index: 10;
`;
type OwnProps = {
children: React.ReactNode;
onOutsideClick?: () => void;
onCancel?: () => void;
onSubmit?: () => void;
};
export function EditableFieldEditMode({
children,
onCancel,
onSubmit,
}: OwnProps) {
const wrapperRef = useRef(null);
useRegisterCloseFieldHandlers(wrapperRef, onSubmit, onCancel);
export function EditableFieldEditMode({ children }: OwnProps) {
return (
<StyledEditableFieldEditModeContainer
data-testid="editable-field-edit-mode-container"
ref={wrapperRef}
>
{children}
<StyledEditableFieldEditModeContainer data-testid="editable-field-edit-mode-container">
<StyledEditableFieldInput>{children}</StyledEditableFieldInput>
</StyledEditableFieldEditModeContainer>
);
}

View File

@ -1,5 +0,0 @@
import { RoundedLink } from '@/ui/link/components/RoundedLink';
export function FieldDisplayURL({ URL }: { URL: string | undefined }) {
return <RoundedLink href={URL ? 'https://' + URL : ''}>{URL}</RoundedLink>;
}

View File

@ -1,9 +1,7 @@
import { useContext } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { IconCheck, IconX } from '@/ui/icon';
import { BooleanInput } from '@/ui/input/components/BooleanInput';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
import { EditableFieldEntityIdContext } from '../contexts/EditableFieldEntityIdContext';
@ -12,16 +10,6 @@ import { genericEntityFieldFamilySelector } from '../states/selectors/genericEnt
import { FieldDefinition } from '../types/FieldDefinition';
import { FieldBooleanMetadata } from '../types/FieldMetadata';
const StyledEditableBooleanFieldContainer = styled.div`
align-items: center;
cursor: pointer;
display: flex;
`;
const StyledEditableBooleanFieldValue = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
`;
export function GenericEditableBooleanFieldDisplayMode() {
const currentEditableFieldEntityId = useContext(EditableFieldEntityIdContext);
const currentEditableFieldDefinition = useContext(
@ -37,32 +25,20 @@ export function GenericEditableBooleanFieldDisplayMode() {
}),
);
const theme = useTheme();
const updateField = useUpdateGenericEntityField();
function toggleValue() {
const newToggledValue = !fieldValue;
setFieldValue(newToggledValue);
function handleSubmit(newValue: boolean) {
if (currentEditableFieldEntityId && updateField) {
updateField(
currentEditableFieldEntityId,
currentEditableFieldDefinition,
newToggledValue,
newValue,
);
// TODO: use optimistic effect instead, but needs generic refactor
setFieldValue(newValue);
}
}
return (
<StyledEditableBooleanFieldContainer onClick={toggleValue}>
{fieldValue ? (
<IconCheck size={theme.icon.size.sm} />
) : (
<IconX size={theme.icon.size.sm} />
)}
<StyledEditableBooleanFieldValue>
{fieldValue ? 'True' : 'False'}
</StyledEditableBooleanFieldValue>
</StyledEditableBooleanFieldContainer>
);
return <BooleanInput value={fieldValue} onToggle={handleSubmit} />;
}

View File

@ -1,7 +1,7 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { DateInputDisplay } from '@/ui/input/date/components/DateInputDisplay';
import { DateInputDisplay } from '@/ui/input/components/DateInputDisplay';
import { parseDate } from '~/utils/date-utils';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';

View File

@ -1,7 +1,7 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { PhoneInputDisplay } from '@/ui/input/phone/components/PhoneInputDisplay';
import { PhoneDisplay } from '@/ui/content-display/components/PhoneDisplay';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
@ -35,7 +35,7 @@ export function GenericEditablePhoneField() {
useEditButton
IconLabel={currentEditableFieldDefinition.Icon}
editModeContent={<GenericEditablePhoneFieldEditMode />}
displayModeContent={<PhoneInputDisplay value={fieldValue} />}
displayModeContent={<PhoneDisplay value={fieldValue} />}
isDisplayModeContentEmpty={!fieldValue}
/>
</RecoilScope>

View File

@ -1,13 +1,15 @@
import { useContext, useRef, useState } from 'react';
import { useContext } from 'react';
import { isPossiblePhoneNumber } from 'react-phone-number-input';
import { useRecoilState } from 'recoil';
import { TextInputEdit } from '@/ui/input/text/components/TextInputEdit';
import { PhoneInput } from '@/ui/input/components/PhoneInput';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
import { EditableFieldEntityIdContext } from '../contexts/EditableFieldEntityIdContext';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
import { useFieldInputEventHandlers } from '../hooks/useFieldInputEventHandlers';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/selectors/genericEntityFieldFamilySelector';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
import { FieldDefinition } from '../types/FieldDefinition';
import { FieldPhoneMetadata } from '../types/FieldMetadata';
@ -27,46 +29,34 @@ export function GenericEditablePhoneFieldEditMode() {
}),
);
const [internalValue, setInternalValue] = useState(fieldValue);
const updateField = useUpdateGenericEntityField();
const wrapperRef = useRef(null);
function handleSubmit(newValue: string) {
if (!isPossiblePhoneNumber(newValue)) return;
useRegisterCloseFieldHandlers(wrapperRef, handleSubmit, onCancel);
function handleSubmit() {
if (internalValue === fieldValue) return;
setFieldValue(internalValue);
setFieldValue(newValue);
if (currentEditableFieldEntityId && updateField) {
updateField(
currentEditableFieldEntityId,
currentEditableFieldDefinition,
internalValue,
newValue,
);
}
}
function onCancel() {
setFieldValue(fieldValue);
}
function handleChange(newValue: string) {
setInternalValue(newValue);
}
const { handleEnter, handleEscape, handleClickOutside } =
useFieldInputEventHandlers({
onSubmit: handleSubmit,
});
return (
<div ref={wrapperRef}>
<TextInputEdit
autoFocus
placeholder={currentEditableFieldDefinition.metadata.placeHolder}
value={internalValue}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
</div>
<PhoneInput
value={fieldValue ?? ''}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
hotkeyScope={EditableFieldHotkeyScope.EditableField}
/>
);
}

View File

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { TextDisplay } from '@/ui/content-display/components/TextDisplay';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
@ -33,7 +34,7 @@ export function GenericEditableTextField() {
<EditableField
IconLabel={currentEditableFieldDefinition.Icon}
editModeContent={<GenericEditableTextFieldEditMode />}
displayModeContent={fieldValue}
displayModeContent={<TextDisplay text={fieldValue} />}
isDisplayModeContentEmpty={!fieldValue}
/>
</RecoilScope>

View File

@ -1,13 +1,14 @@
import { useContext, useRef, useState } from 'react';
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { TextInputEdit } from '@/ui/input/text/components/TextInputEdit';
import { TextInput } from '@/ui/input/components/TextInput';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
import { EditableFieldEntityIdContext } from '../contexts/EditableFieldEntityIdContext';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
import { useFieldInputEventHandlers } from '../hooks/useFieldInputEventHandlers';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/selectors/genericEntityFieldFamilySelector';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
import { FieldDefinition } from '../types/FieldDefinition';
import { FieldTextMetadata } from '../types/FieldMetadata';
@ -27,46 +28,35 @@ export function GenericEditableTextFieldEditMode() {
}),
);
const [internalValue, setInternalValue] = useState(fieldValue);
const updateField = useUpdateGenericEntityField();
const wrapperRef = useRef(null);
useRegisterCloseFieldHandlers(wrapperRef, handleSubmit, onCancel);
function handleSubmit() {
if (internalValue === fieldValue) return;
setFieldValue(internalValue);
function handleSubmit(newValue: string) {
if (currentEditableFieldEntityId && updateField) {
updateField(
currentEditableFieldEntityId,
currentEditableFieldDefinition,
internalValue,
newValue,
);
// TODO: use optimistic effect instead, but needs generic refactor
setFieldValue(newValue);
}
}
function onCancel() {
setFieldValue(fieldValue);
}
function handleChange(newValue: string) {
setInternalValue(newValue);
}
const { handleEnter, handleEscape, handleClickOutside } =
useFieldInputEventHandlers({
onSubmit: handleSubmit,
});
return (
<div ref={wrapperRef}>
<TextInputEdit
autoFocus
placeholder={currentEditableFieldDefinition.metadata.placeHolder}
value={internalValue}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
</div>
<TextInput
placeholder={currentEditableFieldDefinition.metadata.placeHolder}
autoFocus
value={fieldValue ?? ''}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
hotkeyScope={EditableFieldHotkeyScope.EditableField}
/>
);
}

View File

@ -1,6 +1,7 @@
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { URLDisplay } from '@/ui/content-display/components/URLDisplay';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
@ -11,7 +12,6 @@ import { FieldDefinition } from '../types/FieldDefinition';
import { FieldNumberMetadata } from '../types/FieldMetadata';
import { EditableField } from './EditableField';
import { FieldDisplayURL } from './FieldDisplayURL';
import { GenericEditableURLFieldEditMode } from './GenericEditableURLFieldEditMode';
export function GenericEditableURLField() {
@ -35,7 +35,7 @@ export function GenericEditableURLField() {
useEditButton
IconLabel={currentEditableFieldDefinition.Icon}
editModeContent={<GenericEditableURLFieldEditMode />}
displayModeContent={<FieldDisplayURL URL={fieldValue} />}
displayModeContent={<URLDisplay value={fieldValue} />}
isDisplayModeContentEmpty={!fieldValue}
isDisplayModeFixHeight
/>

View File

@ -1,13 +1,14 @@
import { useContext, useRef, useState } from 'react';
import { useContext } from 'react';
import { useRecoilState } from 'recoil';
import { TextInputEdit } from '@/ui/input/text/components/TextInputEdit';
import { TextInput } from '@/ui/input/components/TextInput';
import { EditableFieldDefinitionContext } from '../contexts/EditableFieldDefinitionContext';
import { EditableFieldEntityIdContext } from '../contexts/EditableFieldEntityIdContext';
import { useRegisterCloseFieldHandlers } from '../hooks/useRegisterCloseFieldHandlers';
import { useFieldInputEventHandlers } from '../hooks/useFieldInputEventHandlers';
import { useUpdateGenericEntityField } from '../hooks/useUpdateGenericEntityField';
import { genericEntityFieldFamilySelector } from '../states/selectors/genericEntityFieldFamilySelector';
import { EditableFieldHotkeyScope } from '../types/EditableFieldHotkeyScope';
import { FieldDefinition } from '../types/FieldDefinition';
import { FieldURLMetadata } from '../types/FieldMetadata';
@ -29,46 +30,34 @@ export function GenericEditableURLFieldEditMode() {
}),
);
const [internalValue, setInternalValue] = useState(fieldValue);
const updateField = useUpdateGenericEntityField();
const wrapperRef = useRef(null);
useRegisterCloseFieldHandlers(wrapperRef, handleSubmit, onCancel);
function handleSubmit() {
if (internalValue === fieldValue) return;
setFieldValue(internalValue);
function handleSubmit(newValue: string) {
setFieldValue(newValue);
if (currentEditableFieldEntityId && updateField) {
updateField(
currentEditableFieldEntityId,
currentEditableFieldDefinition,
internalValue,
newValue,
);
}
}
function onCancel() {
setFieldValue(fieldValue);
}
function handleChange(newValue: string) {
setInternalValue(newValue);
}
const { handleEnter, handleEscape, handleClickOutside } =
useFieldInputEventHandlers({
onSubmit: handleSubmit,
});
return (
<div ref={wrapperRef}>
<TextInputEdit
autoFocus
placeholder={currentEditableFieldDefinition.metadata.placeHolder}
value={internalValue}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
</div>
<TextInput
placeholder={currentEditableFieldDefinition.metadata.placeHolder}
autoFocus
value={fieldValue ?? ''}
onClickOutside={handleClickOutside}
onEnter={handleEnter}
onEscape={handleEscape}
hotkeyScope={EditableFieldHotkeyScope.EditableField}
/>
);
}

View File

@ -0,0 +1,28 @@
import { useEditableField } from './useEditableField';
export function useFieldInputEventHandlers<T>({
onSubmit,
onCancel,
}: {
onSubmit?: (newValue: T) => void;
onCancel?: () => void;
}) {
const { closeEditableField, isFieldInEditMode } = useEditableField();
return {
handleClickOutside: (_event: MouseEvent | TouchEvent, newValue: T) => {
if (isFieldInEditMode) {
onSubmit?.(newValue);
closeEditableField();
}
},
handleEscape: () => {
closeEditableField();
onCancel?.();
},
handleEnter: (newValue: T) => {
onSubmit?.(newValue);
closeEditableField();
},
};
}

View File

@ -1,7 +1,7 @@
import { EditableField } from '@/ui/editable-field/components/EditableField';
import { FieldRecoilScopeContext } from '@/ui/editable-field/states/recoil-scope-contexts/FieldRecoilScopeContext';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import { DateInputDisplay } from '@/ui/input/date/components/DateInputDisplay';
import { DateInputDisplay } from '@/ui/input/components/DateInputDisplay';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
import { parseDate } from '~/utils/date-utils';

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { DateInputEdit } from '@/ui/input/date/components/DateInputEdit';
import { DateInputEdit } from '@/ui/input/components/DateInputEdit';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { parseDate } from '~/utils/date-utils';

View File

@ -1,65 +0,0 @@
import { useEffect, useState } from 'react';
import { EditableField } from '@/ui/editable-field/components/EditableField';
import { FieldRecoilScopeContext } from '@/ui/editable-field/states/recoil-scope-contexts/FieldRecoilScopeContext';
import { IconComponent } from '@/ui/icon/types/IconComponent';
import { PhoneInputDisplay } from '@/ui/input/phone/components/PhoneInputDisplay';
import { TextInputEdit } from '@/ui/input/text/components/TextInputEdit';
import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope';
type OwnProps = {
Icon?: IconComponent;
placeholder?: string;
value: string | null | undefined;
onSubmit?: (newValue: string) => void;
};
export function PhoneEditableField({
Icon,
placeholder,
value,
onSubmit,
}: OwnProps) {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
async function handleChange(newValue: string) {
setInternalValue(newValue);
}
async function handleSubmit() {
if (!internalValue) return;
onSubmit?.(internalValue);
}
async function handleCancel() {
setInternalValue(value);
}
return (
<RecoilScope SpecificContext={FieldRecoilScopeContext}>
<EditableField
onSubmit={handleSubmit}
onCancel={handleCancel}
IconLabel={Icon}
editModeContent={
<TextInputEdit
placeholder={placeholder ?? ''}
autoFocus
value={internalValue ?? ''}
onChange={(newValue: string) => {
handleChange(newValue);
}}
/>
}
displayModeContent={<PhoneInputDisplay value={internalValue ?? ''} />}
isDisplayModeContentEmpty={!(internalValue !== '')}
useEditButton
/>
</RecoilScope>
);
}

View File

@ -1,31 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconPhone } from '@/ui/icon';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { PhoneEditableField } from '../PhoneEditableField';
const meta: Meta<typeof PhoneEditableField> = {
title: 'UI/EditableField/PhoneEditableField',
component: PhoneEditableField,
decorators: [ComponentWithRouterDecorator],
argTypes: {
Icon: {
type: 'boolean',
mapping: {
true: IconPhone,
false: undefined,
},
},
},
args: {
value: '+33714446494',
Icon: IconPhone,
placeholder: 'Phone',
},
};
export default meta;
type Story = StoryObj<typeof PhoneEditableField>;
export const Default: Story = {};