Lucas/t 231 timebox i can create a company at the same time im creating (#140)

This PR is a bit messy:

adding graphql schema
adding create company creation on company select on People page
some frontend refactoring to be continued

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2023-05-25 23:09:23 +02:00
committed by GitHub
parent fecf45f3bc
commit b0044ed1a2
533 changed files with 20601 additions and 333 deletions

View File

@ -22,6 +22,7 @@
"react": "^18.2.0",
"react-datepicker": "^4.11.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.0",
"react-icons": "^4.8.0",
"react-router-dom": "^6.4.4",
"uuid": "^9.0.0",
@ -24939,6 +24940,15 @@
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
"integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
},
"node_modules/react-hotkeys-hook": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-4.4.0.tgz",
"integrity": "sha512-wOaCWLwgT/f895CMJrR9hmzVf+gfL8IpjWDXWXKngBp9i6Xqzf0tvLv4VI8l3Vlsg/cc4C/Iik3Ck76L/Hj0tw==",
"peerDependencies": {
"react": ">=16.8.1",
"react-dom": ">=16.8.1"
}
},
"node_modules/react-icons": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz",

View File

@ -17,6 +17,7 @@
"react": "^18.2.0",
"react-datepicker": "^4.11.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.4.0",
"react-icons": "^4.8.0",
"react-router-dom": "^6.4.4",
"uuid": "^9.0.0",

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
export const CellBaseContainer = styled.div`
position: relative;
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
user-select: none;
`;

View File

@ -0,0 +1,28 @@
import styled from '@emotion/styled';
type OwnProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
export const CellEditModeContainer = styled.div<OwnProps>`
display: flex;
align-items: center;
min-width: 100%;
min-height: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
background: ${(props) => props.theme.primaryBackground};
border: 1px solid ${(props) => props.theme.primaryBorder};
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.16);
z-index: 1;
border-radius: 4px;
backdrop-filter: blur(20px);
`;

View File

@ -0,0 +1,12 @@
import styled from '@emotion/styled';
export const CellNormalModeContainer = styled.div`
display: flex;
align-items: center;
width: calc(100% - ${(props) => props.theme.spacing(5)});
height: 100%;
overflow: hidden;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
`;

View File

@ -0,0 +1,84 @@
import { ReactElement, useRef } from 'react';
import { useOutsideAlerter } from '../../hooks/useOutsideAlerter';
import { useHotkeys } from 'react-hotkeys-hook';
import { CellBaseContainer } from './CellBaseContainer';
import { CellEditModeContainer } from './CellEditModeContainer';
import { CellNormalModeContainer } from './CellNormalModeContainer';
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
isCreateMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCell({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const editableContainerRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
onOutsideClick?.();
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick],
);
return (
<CellBaseContainer
ref={wrapperRef}
onClick={() => {
onInsideClick && onInsideClick();
}}
>
<CellNormalModeContainer>{nonEditModeContent}</CellNormalModeContainer>
{isEditMode && (
<CellEditModeContainer
ref={editableContainerRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</CellEditModeContainer>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,95 @@
import { ReactElement, useRef } from 'react';
import { useOutsideAlerter } from '../../hooks/useOutsideAlerter';
import { useHotkeys } from 'react-hotkeys-hook';
import { CellBaseContainer } from './CellBaseContainer';
import styled from '@emotion/styled';
import { EditableCellMenuEditModeContainer } from './EditableCellMenuEditModeContainer';
const EditableCellMenuNormalModeContainer = styled.div`
display: flex;
align-items: center;
width: calc(100% - ${(props) => props.theme.spacing(5)});
height: 100%;
overflow: hidden;
`;
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
isCreateMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
// TODO: refactor
export function EditableCellMenu({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const editableContainerRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
onOutsideClick?.();
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick],
);
return (
<CellBaseContainer
ref={wrapperRef}
onClick={() => {
onInsideClick && onInsideClick();
}}
>
<EditableCellMenuNormalModeContainer>
{nonEditModeContent}
</EditableCellMenuNormalModeContainer>
{isEditMode && (
<EditableCellMenuEditModeContainer
ref={editableContainerRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</EditableCellMenuEditModeContainer>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
type OwnProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
// TODO: refactor
export const EditableCellMenuEditModeContainer = styled.div<OwnProps>`
display: flex;
align-items: center;
min-width: 100%;
min-height: 100%;
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
background: ${(props) => props.theme.primaryBackground};
border: 1px solid ${(props) => props.theme.primaryBorder};
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.16);
z-index: 1;
border-radius: 4px;
backdrop-filter: blur(20px);
`;

View File

@ -1,99 +0,0 @@
import styled from '@emotion/styled';
import { ReactElement, useRef } from 'react';
import { useOutsideAlerter } from '../../hooks/useOutsideAlerter';
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
const StyledWrapper = styled.div`
position: relative;
box-sizing: border-box;
height: 32px;
display: flex;
align-items: center;
width: 100%;
cursor: pointer;
user-select: none;
`;
type StyledEditModeContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
const StyledNonEditModeContainer = styled.div`
display: flex;
align-items: center;
width: calc(100% - ${(props) => props.theme.spacing(5)});
height: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
overflow: hidden;
`;
const StyledEditModeContainer = styled.div<StyledEditModeContainerProps>`
display: flex;
align-items: center;
min-width: 100%;
min-height: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
background: ${(props) => props.theme.primaryBackground};
border: 1px solid ${(props) => props.theme.primaryBorder};
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.16);
z-index: 1;
border-radius: 4px;
backdrop-filter: blur(20px);
`;
function EditableCellWrapper({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
onOutsideClick && onOutsideClick();
});
return (
<StyledWrapper
ref={wrapperRef}
onClick={() => {
onInsideClick && onInsideClick();
}}
>
<StyledNonEditModeContainer>
{nonEditModeContent}
</StyledNonEditModeContainer>
{isEditMode && (
<StyledEditModeContainer
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</StyledEditModeContainer>
)}
</StyledWrapper>
);
}
export default EditableCellWrapper;

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { ChangeEvent, ComponentType, useRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import { EditableCell } from './EditableCell';
export type EditableChipProps = {
placeholder?: string;
@ -11,16 +11,33 @@ export type EditableChipProps = {
ChipComponent: ComponentType<{ name: string; picture: string }>;
};
// TODO: refactor
const StyledInplaceInput = styled.input`
width: 100%;
border: none;
outline: none;
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
&::placeholder {
font-weight: 'bold';
color: props.theme.text20;
}
`;
const StyledInplaceShow = styled.input`
width: 100%;
border: none;
outline: none;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
&::placeholder {
font-weight: 'bold';
color: props.theme.text20;
}
`;
function EditableChip({
value,
placeholder,
@ -34,7 +51,7 @@ function EditableChip({
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCellWrapper
<EditableCell
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
@ -51,8 +68,12 @@ function EditableChip({
}}
/>
}
nonEditModeContent={<ChipComponent name={inputValue} picture={picture} />}
></EditableCellWrapper>
nonEditModeContent={
<StyledInplaceShow>
<ChipComponent name={inputValue} picture={picture} />
</StyledInplaceShow>
}
></EditableCell>
);
}

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { forwardRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import { EditableCell } from './EditableCell';
import DatePicker from '../form/DatePicker';
import { modalBackground } from '../../layout/styles/themes';
import { humanReadableDate } from '../../services/utils';
@ -57,7 +57,7 @@ function EditableDate({
};
return (
<EditableCellWrapper
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
@ -80,7 +80,7 @@ function EditableDate({
<div>{inputValue && humanReadableDate(inputValue)}</div>
</StyledContainer>
}
></EditableCellWrapper>
></EditableCell>
);
}

View File

@ -0,0 +1,77 @@
import styled from '@emotion/styled';
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
import { EditableCell } from './EditableCell';
type OwnProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
nonEditModeContent: ReactElement;
onChange: (firstValue: string, secondValue: string) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
& > input:last-child {
padding-left: ${(props) => props.theme.spacing(2)};
border-left: 1px solid ${(props) => props.theme.primaryBorder};
}
`;
const StyledEditInplaceInput = styled.input`
width: 45%;
border: none;
outline: none;
height: 18px;
&::placeholder {
font-weight: bold;
color: ${(props) => props.theme.text20};
}
`;
export function EditableDoubleText({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
nonEditModeContent,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
onInsideClick={() => setIsEditMode(true)}
onOutsideClick={() => setIsEditMode(false)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={firstValuePlaceholder}
ref={firstValueInputRef}
value={firstValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
<StyledEditInplaceInput
placeholder={secondValuePlaceholder}
ref={firstValueInputRef}
value={secondValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
</StyledContainer>
}
nonEditModeContent={nonEditModeContent}
></EditableCell>
);
}

View File

@ -1,77 +0,0 @@
import styled from '@emotion/styled';
import { ChangeEvent, useRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import PersonChip from '../chips/PersonChip';
type OwnProps = {
firstname: string;
lastname: string;
changeHandler: (firstname: string, lastname: string) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
& > input:last-child {
padding-left: ${(props) => props.theme.spacing(2)};
border-left: 1px solid ${(props) => props.theme.primaryBorder};
}
`;
const StyledEditInplaceInput = styled.input`
width: 45%;
border: none;
outline: none;
height: 18px;
&::placeholder {
font-weight: bold;
color: ${(props) => props.theme.text20};
}
`;
function EditableFullName({ firstname, lastname, changeHandler }: OwnProps) {
const firstnameInputRef = useRef<HTMLInputElement>(null);
const [firstnameValue, setFirstnameValue] = useState(firstname);
const [lastnameValue, setLastnameValue] = useState(lastname);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCellWrapper
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder="Firstname"
ref={firstnameInputRef}
value={firstnameValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFirstnameValue(event.target.value);
changeHandler(event.target.value, lastnameValue);
}}
/>
<StyledEditInplaceInput
autoFocus
placeholder={'Lastname'}
ref={firstnameInputRef}
value={lastnameValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setLastnameValue(event.target.value);
changeHandler(firstnameValue, event.target.value);
}}
/>
</StyledContainer>
}
nonEditModeContent={
<PersonChip name={firstnameValue + ' ' + lastnameValue} />
}
></EditableCellWrapper>
);
}
export default EditableFullName;

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { ChangeEvent, MouseEvent, useRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import { EditableCell } from './EditableCell';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import Link from '../link/Link';
@ -14,6 +14,7 @@ type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
border: none;
@ -31,7 +32,7 @@ function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCellWrapper
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}

View File

@ -1,34 +1,52 @@
import { ChangeEvent, ComponentType, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import { ChangeEvent, ComponentType, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { useSearch } from '../../services/api/search/search';
import { SearchConfigType } from '../../interfaces/search/interface';
import { AnyEntity } from '../../interfaces/entities/generic.interface';
import { EditableRelationCreateButton } from './EditableRelationCreateButton';
import { isNonEmptyString } from '../../modules/utils/type-guards/isNonEmptyString';
import { isDefined } from '../../modules/utils/type-guards/isDefined';
import { FaPlus } from 'react-icons/fa';
import { HoverableMenuItem } from './HoverableMenuItem';
import { EditableCellMenu } from './EditableCellMenu';
import { CellNormalModeContainer } from './CellNormalModeContainer';
const StyledEditModeContainer = styled.div`
width: 200px;
margin-left: calc(-1 * ${(props) => props.theme.spacing(2)});
margin-right: calc(-1 * ${(props) => props.theme.spacing(2)});
// margin-left: calc(-1 * ${(props) => props.theme.spacing(2)});
// margin-right: calc(-1 * ${(props) => props.theme.spacing(2)});
`;
const StyledEditModeSelectedContainer = styled.div`
height: 31px;
display: flex;
align-items: center;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeSearchContainer = styled.div`
height: 32px;
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeCreateButtonContainer = styled.div`
height: 36px;
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding: ${(props) => props.theme.spacing(1)};
color: ${(props) => props.theme.text60};
`;
const StyledEditModeSearchInput = styled.input`
width: 100%;
border: none;
outline: none;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
&::placeholder {
font-weight: 'bold';
@ -38,9 +56,10 @@ const StyledEditModeSearchInput = styled.input`
const StyledEditModeResults = styled.div`
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeResultItem = styled.div`
height: 32px;
display: flex;
@ -49,6 +68,16 @@ const StyledEditModeResultItem = styled.div`
user-select: none;
`;
const StyledCreateButtonIcon = styled.div`
color: ${(props) => props.theme.text100};
align-self: center;
padding-top: 4px;
`;
const StyledCreateButtonText = styled.div`
color: ${(props) => props.theme.text60};
`;
export type EditableRelationProps<
RelationType extends AnyEntity,
ChipComponentPropsType,
@ -56,14 +85,18 @@ export type EditableRelationProps<
relation?: RelationType | null;
searchPlaceholder: string;
searchConfig: SearchConfigType<RelationType>;
changeHandler: (relation: RelationType) => void;
onChange: (relation: RelationType) => void;
onChangeSearchInput?: (searchInput: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<ChipComponentPropsType>;
chipComponentPropsMapper: (
relation: RelationType,
) => ChipComponentPropsType & JSX.IntrinsicAttributes;
// TODO: refactor, newRelationName is too hard coded.
onCreate?: (newRelationName: string) => void;
};
// TODO: split this component
function EditableRelation<
RelationType extends AnyEntity,
ChipComponentPropsType,
@ -71,72 +104,109 @@ function EditableRelation<
relation,
searchPlaceholder,
searchConfig,
changeHandler,
onChange,
onChangeSearchInput,
editModeHorizontalAlign,
ChipComponent,
chipComponentPropsMapper,
onCreate,
}: EditableRelationProps<RelationType, ChipComponentPropsType>) {
const [selectedRelation, setSelectedRelation] = useState(relation);
const [isEditMode, setIsEditMode] = useState(false);
const [filterSearchResults, setSearchInput, setFilterSearch] =
// TODO: Tie this to a react context
const [filterSearchResults, setSearchInput, setFilterSearch, searchInput] =
useSearch<RelationType>();
useEffect(() => {
if (isDefined(onChangeSearchInput)) {
onChangeSearchInput(searchInput);
}
}, [onChangeSearchInput, searchInput]);
const canCreate = isDefined(onCreate);
const createButtonIsVisible =
canCreate && isEditMode && isNonEmptyString(searchInput);
function handleCreateNewRelationButtonClick() {
onCreate?.(searchInput);
setIsEditMode(false);
}
return (
<EditableCellWrapper
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
<>
<EditableCellMenu
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchConfig);
setSearchInput(event.target.value);
}}
/>
</StyledEditModeSearchContainer>
{createButtonIsVisible && (
<StyledEditModeCreateButtonContainer>
<HoverableMenuItem>
<EditableRelationCreateButton
onClick={handleCreateNewRelationButtonClick}
>
<StyledCreateButtonIcon>
<FaPlus />
</StyledCreateButtonIcon>
<StyledCreateButtonText>Create new</StyledCreateButtonText>
</EditableRelationCreateButton>
</HoverableMenuItem>
</StyledEditModeCreateButtonContainer>
)}
<StyledEditModeResults>
{filterSearchResults.results &&
filterSearchResults.results.map((result, index) => (
<StyledEditModeResultItem
key={index}
onClick={() => {
onChange(result.value);
setIsEditMode(false);
}}
>
<HoverableMenuItem>
<ChipComponent
{...chipComponentPropsMapper(result.value)}
/>
</HoverableMenuItem>
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{selectedRelation ? (
<ChipComponent {...chipComponentPropsMapper(selectedRelation)} />
nonEditModeContent={
<CellNormalModeContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchConfig);
setSearchInput(event.target.value);
}}
/>
</StyledEditModeSearchContainer>
<StyledEditModeResults>
{filterSearchResults.results &&
filterSearchResults.results.map((result, index) => (
<StyledEditModeResultItem
key={index}
onClick={() => {
setSelectedRelation(result.value);
changeHandler(result.value);
setIsEditMode(false);
}}
>
<ChipComponent {...chipComponentPropsMapper(result.value)} />
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
nonEditModeContent={
<div>
{selectedRelation ? (
<ChipComponent {...chipComponentPropsMapper(selectedRelation)} />
) : (
<></>
)}
</div>
}
/>
</CellNormalModeContainer>
}
/>
</>
);
}

View File

@ -0,0 +1,24 @@
import styled from '@emotion/styled';
export const EditableRelationCreateButton = styled.button`
display: flex;
align-items: center;
border: none;
font-size: ${(props) => props.theme.fontSizeMedium};
cursor: pointer;
user-select: none;
padding-top: ${(props) => props.theme.spacing(1)};
padding-bottom: ${(props) => props.theme.spacing(1)};
padding-left: ${(props) => props.theme.spacing(1)};
font-family: 'Inter';
border-radius: 4px;
width: 100%;
height: 31px;
background: none;
gap: 8px;
// :hover {
// background: rgba(0, 0, 0, 0.04);
// color: ${(props) => props.theme.text100};
// }
// margin-bottom: calc(${(props) => props.theme.spacing(1)} / 2);
`;

View File

@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { ChangeEvent, useRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import { EditableCell } from './EditableCell';
type OwnProps = {
placeholder?: string;
@ -13,6 +13,7 @@ type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
border: none;
@ -40,7 +41,7 @@ function EditableText({
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCellWrapper
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
@ -59,7 +60,7 @@ function EditableText({
/>
}
nonEditModeContent={<StyledNoEditText>{inputValue}</StyledNoEditText>}
></EditableCellWrapper>
></EditableCell>
);
}

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
export const HoverableMenuItem = styled.div`
position: relative;
box-sizing: border-box;
height: 100%;
width: 100%;
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0);
transition: background 0.1s ease;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;

View File

@ -1,4 +1,4 @@
import EditableFullName from '../EditableFullName';
import { EditablePeopleFullName } from '../../people/EditablePeopleFullName';
import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../../../layout/styles/themes';
import { StoryFn } from '@storybook/react';
@ -6,23 +6,23 @@ import { MemoryRouter } from 'react-router-dom';
const component = {
title: 'EditableFullName',
component: EditableFullName,
component: EditablePeopleFullName,
};
type OwnProps = {
firstname: string;
lastname: string;
changeHandler: (firstname: string, lastname: string) => void;
onChange: (firstname: string, lastname: string) => void;
};
export default component;
const Template: StoryFn<typeof EditableFullName> = (args: OwnProps) => {
const Template: StoryFn<typeof EditablePeopleFullName> = (args: OwnProps) => {
return (
<MemoryRouter>
<ThemeProvider theme={lightTheme}>
<div data-testid="content-editable-parent">
<EditableFullName {...args} />
<EditablePeopleFullName {...args} />
</div>
</ThemeProvider>
</MemoryRouter>
@ -33,7 +33,7 @@ export const EditableFullNameStory = Template.bind({});
EditableFullNameStory.args = {
firstname: 'John',
lastname: 'Doe',
changeHandler: () => {
console.log('changed');
onChange: () => {
console.log('validated');
},
};

View File

@ -87,7 +87,7 @@ EditableRelationStory.args = {
: undefined,
};
},
changeHandler: (relation: Company) => {
onChange: (relation: Company) => {
console.log('changed', relation);
},
searchConfig: {

View File

@ -15,7 +15,7 @@ it('Checks the EditableRelation editing event bubbles up', async () => {
Company,
CompanyChipPropsType
>)}
changeHandler={func}
onChange={func}
/>,
);

View File

@ -0,0 +1,65 @@
import styled from '@emotion/styled';
import { ChangeEvent, useRef } from 'react';
type OwnProps = {
leftValue: string;
rightValue: string;
leftValuePlaceholder: string;
rightValuePlaceholder: string;
onChange: (leftValue: string, rightValue: string) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
& > input:last-child {
padding-left: ${(props) => props.theme.spacing(2)};
border-left: 1px solid ${(props) => props.theme.primaryBorder};
}
`;
const StyledEditInplaceInput = styled.input`
width: 45%;
border: none;
outline: none;
height: 18px;
&::placeholder {
font-weight: bold;
color: ${(props) => props.theme.text20};
}
`;
export function DoubleTextInput({
leftValue,
rightValue,
leftValuePlaceholder,
rightValuePlaceholder,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
return (
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={leftValuePlaceholder}
ref={firstValueInputRef}
value={leftValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, rightValue);
}}
/>
<StyledEditInplaceInput
placeholder={rightValuePlaceholder}
ref={firstValueInputRef}
value={rightValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(leftValue, event.target.value);
}}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,39 @@
import { useState } from 'react';
import PersonChip from '../chips/PersonChip';
import { EditableDoubleText } from '../editable-cell/EditableDoubleText';
type OwnProps = {
firstname: string;
lastname: string;
onChange: (firstname: string, lastname: string) => void;
};
export function EditablePeopleFullName({
firstname,
lastname,
onChange,
}: OwnProps) {
const [firstnameValue, setFirstnameValue] = useState(firstname);
const [lastnameValue, setLastnameValue] = useState(lastname);
function handleDoubleTextChange(
firstValue: string,
secondValue: string,
): void {
setFirstnameValue(firstValue);
setLastnameValue(secondValue);
onChange(firstnameValue, lastnameValue);
}
return (
<EditableDoubleText
firstValue={firstnameValue}
secondValue={lastnameValue}
firstValuePlaceholder="First name"
secondValuePlaceholder="Last name"
onChange={handleDoubleTextChange}
nonEditModeContent={<PersonChip name={firstname + ' ' + lastname} />}
/>
);
}

View File

@ -0,0 +1,111 @@
import {
QueryMode,
useInsertCompanyMutation,
useUpdatePeopleMutation,
} from '../../generated/graphql';
import {
Person,
mapToGqlPerson,
} from '../../interfaces/entities/person.interface';
import {
Company,
mapToCompany,
} from '../../interfaces/entities/company.interface';
import CompanyChip, { CompanyChipPropsType } from '../chips/CompanyChip';
import EditableRelation from '../editable-cell/EditableRelation';
import { SEARCH_COMPANY_QUERY } from '../../services/api/search/search';
import { SearchConfigType } from '../../interfaces/search/interface';
import { useState } from 'react';
import { PeopleCompanyCreateCell } from './PeopleCompanyCreateCell';
import { v4 } from 'uuid';
export type OwnProps = {
people: Person;
};
export function PeopleCompanyCell({ people }: OwnProps) {
const [isCreating, setIsCreating] = useState(false);
const [insertCompany] = useInsertCompanyMutation();
const [updatePeople] = useUpdatePeopleMutation();
const [initialCompanyName, setInitialCompanyName] = useState('');
async function handleCompanyCreate(
companyName: string,
companyDomainName: string,
) {
const newCompanyId = v4();
try {
await insertCompany({
variables: {
id: newCompanyId,
name: companyName,
domain_name: companyDomainName,
address: '',
created_at: new Date().toISOString(),
},
});
await updatePeople({
variables: {
...mapToGqlPerson(people),
company_id: newCompanyId,
},
});
} catch (error) {
// TODO: handle error better
console.log(error);
}
setIsCreating(false);
}
// TODO: should be replaced with search context
function handleChangeSearchInput(searchInput: string) {
setInitialCompanyName(searchInput);
}
return isCreating ? (
<PeopleCompanyCreateCell
initialCompanyName={initialCompanyName}
onCreate={handleCompanyCreate}
/>
) : (
<EditableRelation<Company, CompanyChipPropsType>
relation={people.company}
searchPlaceholder="Company"
ChipComponent={CompanyChip}
chipComponentPropsMapper={(company): CompanyChipPropsType => {
return {
name: company.name || '',
picture: `https://www.google.com/s2/favicons?domain=${company.domainName}&sz=256`,
};
}}
onChange={async (relation) => {
await updatePeople({
variables: {
...mapToGqlPerson(people),
company_id: relation.id,
},
});
}}
onChangeSearchInput={handleChangeSearchInput}
searchConfig={
{
query: SEARCH_COMPANY_QUERY,
template: (searchInput: string) => ({
name: { contains: `%${searchInput}%`, mode: QueryMode.Insensitive },
}),
resultMapper: (company) => ({
render: (company) => company.name,
value: mapToCompany(company),
}),
} satisfies SearchConfigType<Company>
}
onCreate={() => {
setIsCreating(true);
}}
/>
);
}

View File

@ -0,0 +1,57 @@
import { useRef, useState } from 'react';
import { DoubleTextInput } from '../inputs/DoubleTextInput';
import { useListenClickOutsideArrayOfRef } from '../../modules/ui/hooks/useListenClickOutsideArrayOfRef';
import { useHotkeys } from 'react-hotkeys-hook';
import { CellBaseContainer } from '../editable-cell/CellBaseContainer';
import { CellEditModeContainer } from '../editable-cell/CellEditModeContainer';
type OwnProps = {
initialCompanyName: string;
onCreate: (companyName: string, companyDomainName: string) => void;
};
export function PeopleCompanyCreateCell({
initialCompanyName,
onCreate,
}: OwnProps) {
const [companyName, setCompanyName] = useState(initialCompanyName);
const [companyDomainName, setCompanyDomainName] = useState('');
const containerRef = useRef(null);
useListenClickOutsideArrayOfRef([containerRef], () => {
onCreate(companyName, companyDomainName);
});
useHotkeys(
'enter, escape',
() => {
onCreate(companyName, companyDomainName);
},
{
enableOnFormTags: true,
enableOnContentEditable: true,
preventDefault: true,
},
[containerRef, companyName, companyDomainName, onCreate],
);
function handleDoubleTextChange(leftValue: string, rightValue: string): void {
setCompanyDomainName(leftValue);
setCompanyName(rightValue);
}
return (
<CellBaseContainer ref={containerRef}>
<CellEditModeContainer editModeVerticalPosition="over">
<DoubleTextInput
leftValue={companyDomainName}
rightValue={companyName}
leftValuePlaceholder="URL"
rightValuePlaceholder="Name"
onChange={handleDoubleTextChange}
/>
</CellEditModeContainer>
</CellBaseContainer>
);
}

View File

@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import { isDefined } from '../../utils/type-guards/isDefined';
export function useListenClickOutsideArrayOfRef<T extends HTMLElement>(
arrayOfRef: Array<React.RefObject<T>>,
outsideClickCallback: (event?: MouseEvent) => void,
) {
useEffect(() => {
function handleClickOutside(event: any) {
const clickedOnAtLeastOneRef = arrayOfRef
.filter((ref) => !!ref.current)
.some((ref) => ref.current?.contains(event.target as Node));
if (!clickedOnAtLeastOneRef) {
outsideClickCallback(event);
}
}
const hasAtLeastOneRefDefined = arrayOfRef.some((ref) =>
isDefined(ref.current),
);
if (hasAtLeastOneRefDefined) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [arrayOfRef, outsideClickCallback]);
}

View File

@ -0,0 +1,12 @@
export const debounce = <FuncArgs extends any[]>(
func: (...args: FuncArgs) => void,
delay: number,
) => {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: FuncArgs) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
};

View File

@ -0,0 +1,5 @@
export function isDefined<T>(
value: T | undefined | null,
): value is NonNullable<T> {
return value !== undefined && value !== null;
}

View File

@ -0,0 +1,13 @@
export function isNonEmptyArray<T>(
probableArray: T[] | undefined | null,
): probableArray is NonNullable<T[]> {
if (
Array.isArray(probableArray) &&
probableArray.length &&
probableArray.length > 0
) {
return true;
}
return false;
}

View File

@ -0,0 +1,15 @@
import { isDefined } from './isDefined';
export function isNonEmptyString(
probableNonEmptyString: string | undefined | null,
): probableNonEmptyString is string {
if (
isDefined(probableNonEmptyString) &&
typeof probableNonEmptyString === 'string' &&
probableNonEmptyString !== ''
) {
return true;
}
return false;
}

View File

@ -161,7 +161,7 @@ export const useCompaniesColumns = () => {
name: accountOwner.displayName || '',
};
}}
changeHandler={(relation: User) => {
onChange={(relation: User) => {
const company = props.row.original;
if (company.accountOwner) {
company.accountOwner.id = relation.id;

View File

@ -1,13 +1,5 @@
import { useMemo } from 'react';
import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { SEARCH_COMPANY_QUERY } from '../../services/api/search/search';
import { SearchConfigType } from '../../interfaces/search/interface';
import {
Company,
mapToCompany,
} from '../../interfaces/entities/company.interface';
import { Person } from '../../interfaces/entities/person.interface';
import { updatePerson } from '../../services/api/people';
@ -15,13 +7,9 @@ import ColumnHead from '../../components/table/ColumnHead';
import Checkbox from '../../components/form/Checkbox';
import { SelectAllCheckbox } from '../../components/table/SelectAllCheckbox';
import EditablePhone from '../../components/editable-cell/EditablePhone';
import EditableFullName from '../../components/editable-cell/EditableFullName';
import { EditablePeopleFullName } from '../../components/people/EditablePeopleFullName';
import EditableDate from '../../components/editable-cell/EditableDate';
import EditableText from '../../components/editable-cell/EditableText';
import EditableRelation from '../../components/editable-cell/EditableRelation';
import CompanyChip, {
CompanyChipPropsType,
} from '../../components/chips/CompanyChip';
import {
TbBuilding,
TbCalendar,
@ -30,7 +18,7 @@ import {
TbPhone,
TbUser,
} from 'react-icons/tb';
import { QueryMode } from '../../generated/graphql';
import { PeopleCompanyCell } from '../../components/people/PeopleCompanyCell';
const columnHelper = createColumnHelper<Person>();
@ -61,14 +49,15 @@ export const usePeopleColumns = () => {
<ColumnHead viewName="People" viewIcon={<TbUser size={16} />} />
),
cell: (props) => (
<EditableFullName
<EditablePeopleFullName
firstname={props.row.original.firstname || ''}
lastname={props.row.original.lastname || ''}
changeHandler={(firstName: string, lastName: string) => {
onChange={async (firstName: string, lastName: string) => {
const person = props.row.original;
person.firstname = firstName;
person.lastname = lastName;
updatePerson(person);
const returnedOptimisticResponse = await updatePerson(person);
console.log({ returnedOptimisticResponse });
}}
/>
),
@ -95,43 +84,7 @@ export const usePeopleColumns = () => {
header: () => (
<ColumnHead viewName="Company" viewIcon={<TbBuilding size={16} />} />
),
cell: (props) => (
<EditableRelation<Company, CompanyChipPropsType>
relation={props.row.original.company}
searchPlaceholder="Company"
ChipComponent={CompanyChip}
chipComponentPropsMapper={(company): CompanyChipPropsType => {
return {
name: company.name || '',
picture: `https://www.google.com/s2/favicons?domain=${company.domainName}&sz=256`,
};
}}
changeHandler={(relation) => {
const person = props.row.original;
if (person.company) {
person.company.id = relation.id;
} else {
person.company = relation;
}
updatePerson(person);
}}
searchConfig={
{
query: SEARCH_COMPANY_QUERY,
template: (searchInput: string) => ({
name: {
contains: `%${searchInput}%`,
mode: QueryMode.Insensitive,
},
}),
resultMapper: (company) => ({
render: (company) => company.name,
value: mapToCompany(company),
}),
} satisfies SearchConfigType<Company>
}
/>
),
cell: (props) => <PeopleCompanyCell people={props.row.original} />,
size: 150,
}),
columnHelper.accessor('phone', {

View File

@ -99,6 +99,41 @@ export async function updatePerson(
const result = await apiClient.mutate({
mutation: UPDATE_PERSON,
variables: mapToGqlPerson(person),
// TODO: use a mapper?
optimisticResponse: {
__typename: 'people' as const,
id: person.id,
update_people: {
returning: {
id: person.id,
city: 'TEST',
company: {
domain_name: person.company?.domainName,
name: person.company?.name,
id: person.company?.id,
},
email: person.email,
firstname: person.firstname,
lastname: person.lastname,
phone: person.phone,
created_at: person.creationDate,
// city
// company {
// domain_name
// name
// id
// }
// email
// firstname
// id
// lastname
// phone
// created_at
},
},
},
});
return result;
}

View File

@ -5,6 +5,7 @@ import {
AnyEntity,
UnknownType,
} from '../../../interfaces/entities/generic.interface';
import { debounce } from '../../../modules/utils/debounce';
export const SEARCH_PEOPLE_QUERY = gql`
query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) {
@ -48,19 +49,6 @@ export const SEARCH_COMPANY_QUERY = gql`
}
`;
const debounce = <FuncArgs extends any[]>(
func: (...args: FuncArgs) => void,
delay: number,
) => {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: FuncArgs) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
};
export type SearchResultsType<T extends AnyEntity | UnknownType = UnknownType> =
{
results: {
@ -74,6 +62,7 @@ export const useSearch = <T extends AnyEntity | UnknownType = UnknownType>(): [
SearchResultsType<T>,
React.Dispatch<React.SetStateAction<string>>,
React.Dispatch<React.SetStateAction<SearchConfigType<T> | null>>,
string,
] => {
const [searchConfig, setSearchConfig] = useState<SearchConfigType<T> | null>(
null,
@ -81,7 +70,7 @@ export const useSearch = <T extends AnyEntity | UnknownType = UnknownType>(): [
const [searchInput, setSearchInput] = useState<string>('');
const debouncedsetSearchInput = useMemo(
() => debounce(setSearchInput, 500),
() => debounce(setSearchInput, 50),
[],
);
@ -119,11 +108,12 @@ export const useSearch = <T extends AnyEntity | UnknownType = UnknownType>(): [
}
return {
loading: false,
results: searchQueryResults.data.searchResults.map(
// TODO: add proper typing
results: searchQueryResults?.data?.searchResults?.map(
searchConfig.resultMapper,
),
};
}, [searchConfig, searchQueryResults]);
return [searchResults, debouncedsetSearchInput, setSearchConfig];
return [searchResults, debouncedsetSearchInput, setSearchConfig, searchInput];
};