Make phone editable on people's page (#98)
* Make phone editable on people's page * Make City editable --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
30
front/src/components/link/Link.tsx
Normal file
30
front/src/components/link/Link.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Link as ReactLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
href: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledClickable = styled.div`
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Link({ href, children, onClick }: OwnProps) {
|
||||||
|
return (
|
||||||
|
<StyledClickable>
|
||||||
|
<ReactLink onClick={onClick} to={href}>
|
||||||
|
{children}
|
||||||
|
</ReactLink>
|
||||||
|
</StyledClickable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Link;
|
||||||
@ -7,6 +7,7 @@ import { ThemeType } from '../../../layout/styles/themes';
|
|||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
children: ReactElement;
|
children: ReactElement;
|
||||||
onEditModeChange: (isEditMode: boolean) => void;
|
onEditModeChange: (isEditMode: boolean) => void;
|
||||||
|
shouldAlignRight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledWrapper = styled.div`
|
const StyledWrapper = styled.div`
|
||||||
@ -20,19 +21,24 @@ const StyledWrapper = styled.div`
|
|||||||
|
|
||||||
type styledEditModeWrapperProps = {
|
type styledEditModeWrapperProps = {
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
|
shouldAlignRight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const styledEditModeWrapper = (theme: ThemeType) =>
|
const styledEditModeWrapper = (
|
||||||
|
props: styledEditModeWrapperProps & { theme: ThemeType },
|
||||||
|
) =>
|
||||||
css`
|
css`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
left: ${props.shouldAlignRight ? 'auto' : '0'};
|
||||||
|
right: ${props.shouldAlignRight ? '0' : 'auto'};
|
||||||
width: 260px;
|
width: 260px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-left: ${theme.spacing(2)};
|
padding-left: ${props.theme.spacing(2)};
|
||||||
padding-right: ${theme.spacing(2)};
|
padding-right: ${props.theme.spacing(2)};
|
||||||
background: ${theme.primaryBackground};
|
background: ${props.theme.primaryBackground};
|
||||||
border: 1px solid ${theme.primaryBorder};
|
border: 1px solid ${props.theme.primaryBorder};
|
||||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.16);
|
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.16);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -43,10 +49,14 @@ const Container = styled.div<styledEditModeWrapperProps>`
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding-left: ${(props) => props.theme.spacing(2)};
|
padding-left: ${(props) => props.theme.spacing(2)};
|
||||||
padding-right: ${(props) => props.theme.spacing(2)};
|
padding-right: ${(props) => props.theme.spacing(2)};
|
||||||
${(props) => props.isEditMode && styledEditModeWrapper(props.theme)}
|
${(props) => props.isEditMode && styledEditModeWrapper(props)}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function EditableCellWrapper({ children, onEditModeChange }: OwnProps) {
|
function EditableCellWrapper({
|
||||||
|
children,
|
||||||
|
onEditModeChange,
|
||||||
|
shouldAlignRight,
|
||||||
|
}: OwnProps) {
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
||||||
const wrapperRef = useRef(null);
|
const wrapperRef = useRef(null);
|
||||||
@ -63,7 +73,9 @@ function EditableCellWrapper({ children, onEditModeChange }: OwnProps) {
|
|||||||
onEditModeChange(true);
|
onEditModeChange(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container isEditMode={isEditMode}>{children}</Container>
|
<Container shouldAlignRight={shouldAlignRight} isEditMode={isEditMode}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
</StyledWrapper>
|
</StyledWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
75
front/src/components/table/editable-cell/EditablePhone.tsx
Normal file
75
front/src/components/table/editable-cell/EditablePhone.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { ChangeEvent, MouseEvent, useRef, useState } from 'react';
|
||||||
|
import EditableCellWrapper from './EditableCellWrapper';
|
||||||
|
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
|
||||||
|
import Link from '../../link/Link';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
placeholder?: string;
|
||||||
|
value: string;
|
||||||
|
changeHandler: (updated: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StyledEditModeProps = {
|
||||||
|
isEditMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
font-weight: bold;
|
||||||
|
color: ${(props) => props.theme.text20};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [inputValue, setInputValue] = useState(value);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
||||||
|
const onEditModeChange = (isEditMode: boolean) => {
|
||||||
|
setIsEditMode(isEditMode);
|
||||||
|
if (isEditMode) {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditableCellWrapper onEditModeChange={onEditModeChange}>
|
||||||
|
{isEditMode ? (
|
||||||
|
<StyledEditInplaceInput
|
||||||
|
autoFocus
|
||||||
|
isEditMode={isEditMode}
|
||||||
|
placeholder={placeholder || ''}
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
changeHandler(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{isValidPhoneNumber(inputValue) ? (
|
||||||
|
<Link
|
||||||
|
href={parsePhoneNumber(inputValue, 'FR')?.getURI()}
|
||||||
|
onClick={(event: MouseEvent<HTMLElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{parsePhoneNumber(inputValue, 'FR')?.formatInternational() ||
|
||||||
|
inputValue}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="#">{inputValue}</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</EditableCellWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditablePhone;
|
||||||
@ -6,6 +6,7 @@ type OwnProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
content: string;
|
content: string;
|
||||||
changeHandler: (updated: string) => void;
|
changeHandler: (updated: string) => void;
|
||||||
|
shouldAlignRight?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StyledEditModeProps = {
|
type StyledEditModeProps = {
|
||||||
@ -24,7 +25,16 @@ const StyledInplaceInput = styled.input<StyledEditModeProps>`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function EditableCell({ content, placeholder, changeHandler }: OwnProps) {
|
const StyledNoEditText = styled.div`
|
||||||
|
max-width: 200px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function EditableCell({
|
||||||
|
content,
|
||||||
|
placeholder,
|
||||||
|
changeHandler,
|
||||||
|
shouldAlignRight,
|
||||||
|
}: OwnProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [inputValue, setInputValue] = useState(content);
|
const [inputValue, setInputValue] = useState(content);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
@ -37,17 +47,24 @@ function EditableCell({ content, placeholder, changeHandler }: OwnProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditableCellWrapper onEditModeChange={onEditModeChange}>
|
<EditableCellWrapper
|
||||||
<StyledInplaceInput
|
onEditModeChange={onEditModeChange}
|
||||||
isEditMode={isEditMode}
|
shouldAlignRight={shouldAlignRight}
|
||||||
placeholder={placeholder || ''}
|
>
|
||||||
ref={inputRef}
|
{isEditMode ? (
|
||||||
value={inputValue}
|
<StyledInplaceInput
|
||||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
isEditMode={isEditMode}
|
||||||
setInputValue(event.target.value);
|
placeholder={placeholder || ''}
|
||||||
changeHandler(event.target.value);
|
ref={inputRef}
|
||||||
}}
|
value={inputValue}
|
||||||
/>
|
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(event.target.value);
|
||||||
|
changeHandler(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StyledNoEditText>{inputValue}</StyledNoEditText>
|
||||||
|
)}
|
||||||
</EditableCellWrapper>
|
</EditableCellWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import EditablePhone from '../EditablePhone';
|
||||||
|
import { ThemeProvider } from '@emotion/react';
|
||||||
|
import { lightTheme } from '../../../../layout/styles/themes';
|
||||||
|
import { StoryFn } from '@storybook/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
const component = {
|
||||||
|
title: 'EditablePhone',
|
||||||
|
component: EditablePhone,
|
||||||
|
};
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
value: string;
|
||||||
|
changeHandler: (updated: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default component;
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof EditablePhone> = (args: OwnProps) => {
|
||||||
|
return (
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThemeProvider theme={lightTheme}>
|
||||||
|
<div data-testid="content-editable-parent">
|
||||||
|
<EditablePhone {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditablePhoneStory = Template.bind({});
|
||||||
|
EditablePhoneStory.args = {
|
||||||
|
placeholder: 'Test placeholder',
|
||||||
|
value: '+33657646543',
|
||||||
|
changeHandler: () => {
|
||||||
|
console.log('changed');
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
import { fireEvent, render } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { EditablePhoneStory } from '../__stories__/EditablePhone.stories';
|
||||||
|
|
||||||
|
it('Checks the EditablePhone editing event bubbles up', async () => {
|
||||||
|
const func = jest.fn(() => null);
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<EditablePhoneStory value="+33786405315" changeHandler={func} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parent = getByTestId('content-editable-parent');
|
||||||
|
|
||||||
|
const wrapper = parent.querySelector('div');
|
||||||
|
|
||||||
|
if (!wrapper) {
|
||||||
|
throw new Error('Editable input not found');
|
||||||
|
}
|
||||||
|
fireEvent.click(wrapper);
|
||||||
|
|
||||||
|
const editableInput = parent.querySelector('input');
|
||||||
|
|
||||||
|
if (!editableInput) {
|
||||||
|
throw new Error('Editable input not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.change(editableInput, { target: { value: '23' } });
|
||||||
|
expect(func).toBeCalledWith('23');
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@ import { fireEvent, render } from '@testing-library/react';
|
|||||||
|
|
||||||
import { EditableTextStory } from '../__stories__/EditableText.stories';
|
import { EditableTextStory } from '../__stories__/EditableText.stories';
|
||||||
|
|
||||||
it('Checks the EditableCell editing event bubbles up', async () => {
|
it('Checks the EditableText editing event bubbles up', async () => {
|
||||||
const func = jest.fn(() => null);
|
const func = jest.fn(() => null);
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<EditableTextStory content="test" changeHandler={func} />,
|
<EditableTextStory content="test" changeHandler={func} />,
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import {
|
|||||||
import { createColumnHelper } from '@tanstack/react-table';
|
import { createColumnHelper } from '@tanstack/react-table';
|
||||||
import ClickableCell from '../../components/table/ClickableCell';
|
import ClickableCell from '../../components/table/ClickableCell';
|
||||||
import ColumnHead from '../../components/table/ColumnHead';
|
import ColumnHead from '../../components/table/ColumnHead';
|
||||||
import { parsePhoneNumber, CountryCode } from 'libphonenumber-js';
|
|
||||||
import Checkbox from '../../components/form/Checkbox';
|
import Checkbox from '../../components/form/Checkbox';
|
||||||
import HorizontalyAlignedContainer from '../../layout/containers/HorizontalyAlignedContainer';
|
import HorizontalyAlignedContainer from '../../layout/containers/HorizontalyAlignedContainer';
|
||||||
import CompanyChip from '../../components/chips/CompanyChip';
|
import CompanyChip from '../../components/chips/CompanyChip';
|
||||||
@ -31,6 +30,7 @@ import {
|
|||||||
SEARCH_PEOPLE_QUERY,
|
SEARCH_PEOPLE_QUERY,
|
||||||
} from '../../services/search/search';
|
} from '../../services/search/search';
|
||||||
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
|
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
|
||||||
|
import EditablePhone from '../../components/table/editable-cell/EditablePhone';
|
||||||
|
|
||||||
export const availableSorts = [
|
export const availableSorts = [
|
||||||
{
|
{
|
||||||
@ -155,7 +155,7 @@ export const peopleColumns = [
|
|||||||
<EditableText
|
<EditableText
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
content={props.row.original.email}
|
content={props.row.original.email}
|
||||||
changeHandler={(value) => {
|
changeHandler={(value: string) => {
|
||||||
const person = props.row.original;
|
const person = props.row.original;
|
||||||
person.email = value;
|
person.email = value;
|
||||||
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
|
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
|
||||||
@ -179,17 +179,15 @@ export const peopleColumns = [
|
|||||||
columnHelper.accessor('phone', {
|
columnHelper.accessor('phone', {
|
||||||
header: () => <ColumnHead viewName="Phone" viewIcon={<FaPhone />} />,
|
header: () => <ColumnHead viewName="Phone" viewIcon={<FaPhone />} />,
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<ClickableCell
|
<EditablePhone
|
||||||
href={parsePhoneNumber(
|
placeholder="Phone"
|
||||||
props.row.original.phone,
|
value={props.row.original.phone}
|
||||||
props.row.original.countryCode as CountryCode,
|
changeHandler={(value: string) => {
|
||||||
)?.getURI()}
|
const person = props.row.original;
|
||||||
>
|
person.phone = value;
|
||||||
{parsePhoneNumber(
|
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
|
||||||
props.row.original.phone,
|
}}
|
||||||
props.row.original.countryCode as CountryCode,
|
/>
|
||||||
)?.formatInternational() || props.row.original.phone}
|
|
||||||
</ClickableCell>
|
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
columnHelper.accessor('creationDate', {
|
columnHelper.accessor('creationDate', {
|
||||||
@ -215,7 +213,16 @@ export const peopleColumns = [
|
|||||||
columnHelper.accessor('city', {
|
columnHelper.accessor('city', {
|
||||||
header: () => <ColumnHead viewName="City" viewIcon={<FaMapPin />} />,
|
header: () => <ColumnHead viewName="City" viewIcon={<FaMapPin />} />,
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<ClickableCell href="#">{props.row.original.city}</ClickableCell>
|
<EditableText
|
||||||
|
shouldAlignRight={true}
|
||||||
|
placeholder="City"
|
||||||
|
content={props.row.original.city}
|
||||||
|
changeHandler={(value: string) => {
|
||||||
|
const person = props.row.original;
|
||||||
|
person.city = value;
|
||||||
|
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
|
||||||
|
}}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user