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:
12
front/src/components/editable-cell/CellBaseContainer.tsx
Normal file
12
front/src/components/editable-cell/CellBaseContainer.tsx
Normal 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;
|
||||
`;
|
||||
28
front/src/components/editable-cell/CellEditModeContainer.tsx
Normal file
28
front/src/components/editable-cell/CellEditModeContainer.tsx
Normal 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);
|
||||
`;
|
||||
@ -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)};
|
||||
`;
|
||||
84
front/src/components/editable-cell/EditableCell.tsx
Normal file
84
front/src/components/editable-cell/EditableCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
front/src/components/editable-cell/EditableCellMenu.tsx
Normal file
95
front/src/components/editable-cell/EditableCellMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
`;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
77
front/src/components/editable-cell/EditableDoubleText.tsx
Normal file
77
front/src/components/editable-cell/EditableDoubleText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
19
front/src/components/editable-cell/HoverableMenuItem.tsx
Normal file
19
front/src/components/editable-cell/HoverableMenuItem.tsx
Normal 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);
|
||||
}
|
||||
`;
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
@ -87,7 +87,7 @@ EditableRelationStory.args = {
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
changeHandler: (relation: Company) => {
|
||||
onChange: (relation: Company) => {
|
||||
console.log('changed', relation);
|
||||
},
|
||||
searchConfig: {
|
||||
|
||||
@ -15,7 +15,7 @@ it('Checks the EditableRelation editing event bubbles up', async () => {
|
||||
Company,
|
||||
CompanyChipPropsType
|
||||
>)}
|
||||
changeHandler={func}
|
||||
onChange={func}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
65
front/src/components/inputs/DoubleTextInput.tsx
Normal file
65
front/src/components/inputs/DoubleTextInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
front/src/components/people/EditablePeopleFullName.tsx
Normal file
39
front/src/components/people/EditablePeopleFullName.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
111
front/src/components/people/PeopleCompanyCell.tsx
Normal file
111
front/src/components/people/PeopleCompanyCell.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
front/src/components/people/PeopleCompanyCreateCell.tsx
Normal file
57
front/src/components/people/PeopleCompanyCreateCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
12
front/src/modules/utils/debounce.ts
Normal file
12
front/src/modules/utils/debounce.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
5
front/src/modules/utils/type-guards/isDefined.ts
Normal file
5
front/src/modules/utils/type-guards/isDefined.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function isDefined<T>(
|
||||
value: T | undefined | null,
|
||||
): value is NonNullable<T> {
|
||||
return value !== undefined && value !== null;
|
||||
}
|
||||
13
front/src/modules/utils/type-guards/isNonEmptyArray.ts
Normal file
13
front/src/modules/utils/type-guards/isNonEmptyArray.ts
Normal 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;
|
||||
}
|
||||
15
front/src/modules/utils/type-guards/isNonEmptyString.ts
Normal file
15
front/src/modules/utils/type-guards/isNonEmptyString.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user