Create and EditableRelation component and make it generic (#107)

* Create and EditableRelation component and make it generic

* Refactor EditableCell component to be more flexible

* Complete Company picker on people page

* Fix lint
This commit is contained in:
Charles Bochet 2023-05-06 16:08:45 +02:00 committed by GitHub
parent 7ac2f8e1a6
commit 41c46c36ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 637 additions and 198 deletions

View File

@ -1,7 +1,7 @@
import * as React from 'react';
import styled from '@emotion/styled';
type OwnProps = {
export type CompanyChipPropsType = {
name: string;
picture?: string;
};
@ -26,7 +26,7 @@ const StyledContainer = styled.span`
}
`;
function CompanyChip({ name, picture }: OwnProps) {
function CompanyChip({ name, picture }: CompanyChipPropsType) {
return (
<StyledContainer data-testid="company-chip">
{picture && (

View File

@ -1,5 +1,5 @@
import styled from '@emotion/styled';
import { forwardRef, useState } from 'react';
import React, { ReactElement, forwardRef, useState } from 'react';
import ReactDatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
@ -8,6 +8,7 @@ export type DatePickerProps = {
isOpen?: boolean;
date: Date;
onChangeHandler: (date: Date) => void;
customInput?: ReactElement;
};
const StyledContainer = styled.div`
@ -34,11 +35,12 @@ const StyledContainer = styled.div`
}
`;
function DatePicker({ date, onChangeHandler, isOpen }: DatePickerProps) {
function DatePicker({ date, onChangeHandler, customInput }: DatePickerProps) {
const [startDate, setStartDate] = useState(date);
type DivProps = React.HTMLProps<HTMLDivElement>;
const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
const DefaultDateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value &&
@ -54,30 +56,13 @@ function DatePicker({ date, onChangeHandler, isOpen }: DatePickerProps) {
return (
<StyledContainer>
<ReactDatePicker
open={isOpen}
open={true}
selected={startDate}
onChange={(date: Date) => {
setStartDate(date);
onChangeHandler(date);
}}
popperPlacement="bottom"
popperModifiers={[
{
name: 'offset',
options: {
offset: [55, 0],
},
},
{
name: 'preventOverflow',
options: {
rootBoundary: 'viewport',
tether: false,
altAxis: true,
},
},
]}
customInput={<DateDisplay />}
customInput={customInput ? customInput : <DefaultDateDisplay />}
/>
</StyledContainer>
);

View File

@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react';
import { DatePickerStory } from '../__stories__/Datepicker.stories';
import { act } from 'react-dom/test-utils';
it('Checks the datepicker renders', () => {
it('Checks the datepicker renders', async () => {
const changeHandler = jest.fn();
const { getByText } = render(
<DatePickerStory
@ -11,6 +11,11 @@ it('Checks the datepicker renders', () => {
onChangeHandler={changeHandler}
/>,
);
await act(async () => {
expect(getByText('Mar 3, 2021')).toBeInTheDocument();
});
act(() => {
fireEvent.click(getByText('Mar 3, 2021'));
});

View File

@ -151,28 +151,6 @@ function Table<TData extends { id: string }, SortField, FilterProperies>({
</tr>
))}
</tbody>
{table
.getFooterGroups()
.flatMap((group) => group.headers)
.filter((header) => !!header.column.columnDef.footer).length >
0 && (
<tfoot>
{table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id}>
{footerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.footer,
header.getContext(),
)}
</th>
))}
</tr>
))}
</tfoot>
)}
</StyledTable>
</StyledTableScrollableContainer>
</StyledTableWithHeader>

View File

@ -1,13 +1,15 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { ReactElement, useRef, useState } from 'react';
import { ReactElement, useRef } from 'react';
import { useOutsideAlerter } from '../../../hooks/useOutsideAlerter';
import { ThemeType } from '../../../layout/styles/themes';
type OwnProps = {
children: ReactElement;
onEditModeChange: (isEditMode: boolean) => void;
shouldAlignRight?: boolean;
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
const StyledWrapper = styled.div`
@ -19,63 +21,74 @@ const StyledWrapper = styled.div`
width: 100%;
`;
type styledEditModeWrapperProps = {
isEditMode: boolean;
shouldAlignRight?: boolean;
type StyledEditModeContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
const styledEditModeWrapper = (
props: styledEditModeWrapperProps & { theme: ThemeType },
) =>
css`
position: absolute;
left: ${props.shouldAlignRight ? 'auto' : '0'};
right: ${props.shouldAlignRight ? '0' : 'auto'};
width: 260px;
height: 100%;
const StyledNonEditModeContainer = styled.div`
display: flex;
padding-left: ${props.theme.spacing(2)};
padding-right: ${props.theme.spacing(2)};
background: ${props.theme.primaryBackground};
border: 1px solid ${props.theme.primaryBorder};
align-items: center;
width: 100%;
height: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
`;
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);
`;
const Container = styled.div<styledEditModeWrapperProps>`
width: 100%;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
${(props) => props.isEditMode && styledEditModeWrapper(props)}
`;
function EditableCellWrapper({
children,
onEditModeChange,
shouldAlignRight,
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
}: OwnProps) {
const [isEditMode, setIsEditMode] = useState(false);
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, () => {
setIsEditMode(false);
onEditModeChange(false);
onOutsideClick && onOutsideClick();
});
return (
<StyledWrapper
ref={wrapperRef}
onClick={() => {
setIsEditMode(true);
onEditModeChange(true);
onInsideClick && onInsideClick();
}}
>
<Container shouldAlignRight={shouldAlignRight} isEditMode={isEditMode}>
{children}
</Container>
<StyledNonEditModeContainer>
{nonEditModeContent}
</StyledNonEditModeContainer>
{isEditMode && (
<StyledEditModeContainer
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</StyledEditModeContainer>
)}
</StyledWrapper>
);
}

View File

@ -7,23 +7,18 @@ export type EditableChipProps = {
value: string;
picture: string;
changeHandler: (updated: string) => void;
shouldAlignRight?: boolean;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<{ name: string; picture: string }>;
};
type StyledEditModeProps = {
isEditMode: boolean;
};
const StyledInplaceInput = styled.input<StyledEditModeProps>`
const StyledInplaceInput = styled.input`
width: 100%;
border: none;
outline: none;
&::placeholder {
font-weight: ${(props) => (props.isEditMode ? 'bold' : 'normal')};
color: ${(props) =>
props.isEditMode ? props.theme.text20 : 'transparent'};
font-weight: 'bold';
color: props.theme.text20;
}
`;
function EditableChip({
@ -31,25 +26,21 @@ function EditableChip({
placeholder,
changeHandler,
picture,
shouldAlignRight,
editModeHorizontalAlign,
ChipComponent,
}: EditableChipProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
const onEditModeChange = (isEditMode: boolean) => {
setIsEditMode(isEditMode);
};
return (
<EditableCellWrapper
onEditModeChange={onEditModeChange}
shouldAlignRight={shouldAlignRight}
>
{isEditMode ? (
<StyledInplaceInput
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
@ -59,10 +50,9 @@ function EditableChip({
changeHandler(event.target.value);
}}
/>
) : (
<ChipComponent name={value} picture={picture} />
)}
</EditableCellWrapper>
}
nonEditModeContent={<ChipComponent name={value} picture={picture} />}
></EditableCellWrapper>
);
}

View File

@ -1,12 +1,12 @@
import styled from '@emotion/styled';
import { useState } from 'react';
import { forwardRef, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import DatePicker from '../../form/DatePicker';
export type EditableDateProps = {
value: Date;
changeHandler: (date: Date) => void;
shouldAlignRight?: boolean;
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledContainer = styled.div`
@ -16,31 +16,57 @@ const StyledContainer = styled.div`
function EditableDate({
value,
changeHandler,
shouldAlignRight,
editModeHorizontalAlign,
}: EditableDateProps) {
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
const onEditModeChange = (isEditMode: boolean) => {
setIsEditMode(isEditMode);
};
type DivProps = React.HTMLProps<HTMLDivElement>;
const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value &&
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(value as string))}
</div>
),
);
return (
<EditableCellWrapper
onEditModeChange={onEditModeChange}
shouldAlignRight={shouldAlignRight}
>
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledContainer>
<DatePicker
isOpen={isEditMode}
date={inputValue}
onChangeHandler={(date: Date) => {
changeHandler(date);
setInputValue(date);
}}
customInput={<DateDisplay />}
/>
</StyledContainer>
</EditableCellWrapper>
}
nonEditModeContent={
<StyledContainer>
<div>
{inputValue &&
new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(inputValue)}
</div>
</StyledContainer>
}
></EditableCellWrapper>
);
}

View File

@ -40,9 +40,10 @@ function EditableFullName({ firstname, lastname, changeHandler }: OwnProps) {
return (
<EditableCellWrapper
onEditModeChange={(editMode: boolean) => setIsEditMode(editMode)}
>
{isEditMode ? (
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput
autoFocus
@ -65,10 +66,11 @@ function EditableFullName({ firstname, lastname, changeHandler }: OwnProps) {
}}
/>
</StyledContainer>
) : (
}
nonEditModeContent={
<PersonChip name={firstnameValue + ' ' + lastnameValue} />
)}
</EditableCellWrapper>
}
></EditableCellWrapper>
);
}

View File

@ -30,16 +30,12 @@ function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
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 ? (
<EditableCellWrapper
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeContent={
<StyledEditInplaceInput
autoFocus
isEditMode={isEditMode}
@ -51,7 +47,8 @@ function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
changeHandler(event.target.value);
}}
/>
) : (
}
nonEditModeContent={
<div>
{isValidPhoneNumber(inputValue) ? (
<Link
@ -67,8 +64,8 @@ function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
<Link href="#">{inputValue}</Link>
)}
</div>
)}
</EditableCellWrapper>
}
/>
);
}

View File

@ -0,0 +1,135 @@
import { ChangeEvent, ComponentType, useState } from 'react';
import EditableCellWrapper from './EditableCellWrapper';
import styled from '@emotion/styled';
import { useSearch } from '../../../services/search/search';
import { FilterType } from '../table-header/interface';
import { People_Bool_Exp } from '../../../generated/graphql';
const StyledEditModeContainer = styled.div`
width: 200px;
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)};
`;
const StyledEditModeSearchContainer = styled.div`
height: 32px;
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
`;
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';
color: ${(props) => props.theme.text20};
}
`;
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)};
`;
const StyledEditModeResultItem = styled.div`
height: 32px;
display: flex;
align-items: center;
cursor: pointer;
`;
export type EditableRelationProps<RelationType, ChipComponentPropsType> = {
relation: RelationType;
searchPlaceholder: string;
searchFilter: FilterType<People_Bool_Exp>;
changeHandler: (relation: RelationType) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<ChipComponentPropsType>;
chipComponentPropsMapper: (
relation: RelationType,
) => ChipComponentPropsType & JSX.IntrinsicAttributes;
};
function EditableRelation<RelationType, ChipComponentPropsType>({
relation,
searchPlaceholder,
searchFilter,
changeHandler,
editModeHorizontalAlign,
ChipComponent,
chipComponentPropsMapper,
}: EditableRelationProps<RelationType, ChipComponentPropsType>) {
const [selectedRelation, setSelectedRelation] = useState(relation);
const [isEditMode, setIsEditMode] = useState(false);
const [filterSearchResults, setSearchInput, setFilterSearch] = useSearch();
return (
<EditableCellWrapper
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{selectedRelation ? (
<ChipComponent {...chipComponentPropsMapper(selectedRelation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchFilter);
setSearchInput(event.target.value);
}}
/>
</StyledEditModeSearchContainer>
<StyledEditModeResults>
{filterSearchResults.results &&
filterSearchResults.results.map((result) => (
<StyledEditModeResultItem
key={result.value.id}
onClick={() => {
setSelectedRelation(result.value);
changeHandler(result.value);
setIsEditMode(false);
}}
>
<ChipComponent {...chipComponentPropsMapper(result.value)} />
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
nonEditModeContent={
<div>
{selectedRelation ? (
<ChipComponent {...chipComponentPropsMapper(selectedRelation)} />
) : (
<></>
)}
</div>
}
/>
);
}
export default EditableRelation;

View File

@ -6,7 +6,7 @@ type OwnProps = {
placeholder?: string;
content: string;
changeHandler: (updated: string) => void;
shouldAlignRight?: boolean;
editModeHorizontalAlign?: 'left' | 'right';
};
type StyledEditModeProps = {
@ -33,22 +33,19 @@ function EditableText({
content,
placeholder,
changeHandler,
shouldAlignRight,
editModeHorizontalAlign,
}: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(content);
const [isEditMode, setIsEditMode] = useState(false);
const onEditModeChange = (isEditMode: boolean) => {
setIsEditMode(isEditMode);
};
return (
<EditableCellWrapper
onEditModeChange={onEditModeChange}
shouldAlignRight={shouldAlignRight}
>
{isEditMode ? (
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
isEditMode={isEditMode}
placeholder={placeholder || ''}
@ -60,10 +57,9 @@ function EditableText({
changeHandler(event.target.value);
}}
/>
) : (
<StyledNoEditText>{inputValue}</StyledNoEditText>
)}
</EditableCellWrapper>
}
nonEditModeContent={<StyledNoEditText>{inputValue}</StyledNoEditText>}
></EditableCellWrapper>
);
}

View File

@ -0,0 +1,112 @@
import EditableRelation, { EditableRelationProps } from '../EditableRelation';
import { ThemeProvider } from '@emotion/react';
import { lightTheme } from '../../../../layout/styles/themes';
import { StoryFn } from '@storybook/react';
import CompanyChip, { CompanyChipPropsType } from '../../../chips/CompanyChip';
import {
GraphqlQueryCompany,
PartialCompany,
} from '../../../../interfaces/company.interface';
import { MockedProvider } from '@apollo/client/testing';
import { SEARCH_COMPANY_QUERY } from '../../../../services/search/search';
import styled from '@emotion/styled';
import { People_Bool_Exp } from '../../../../generated/graphql';
import { FilterType } from '../../table-header/interface';
import { FaBuilding } from 'react-icons/fa';
const component = {
title: 'editable-cell/EditableRelation',
component: EditableRelation,
};
export default component;
const StyledParent = styled.div`
height: 400px;
`;
const mocks = [
{
request: {
query: SEARCH_COMPANY_QUERY,
variables: {
where: undefined,
},
},
result: {
data: {
companies: [],
},
},
},
{
request: {
query: SEARCH_COMPANY_QUERY,
variables: {
where: { name: { _ilike: '%%' } },
limit: 5,
},
},
result: {
data: {
searchResults: [
{ id: 'abnb', name: 'Airbnb', domain_name: 'abnb.com' },
],
},
},
},
];
const Template: StoryFn<
typeof EditableRelation<PartialCompany, CompanyChipPropsType>
> = (args: EditableRelationProps<PartialCompany, CompanyChipPropsType>) => {
return (
<MockedProvider mocks={mocks}>
<ThemeProvider theme={lightTheme}>
<StyledParent data-testid="content-editable-parent">
<EditableRelation<PartialCompany, CompanyChipPropsType> {...args} />
</StyledParent>
</ThemeProvider>
</MockedProvider>
);
};
export const EditableRelationStory = Template.bind({});
EditableRelationStory.args = {
relation: {
id: '123',
name: 'Heroku',
domain_name: 'heroku.com',
} as PartialCompany,
ChipComponent: CompanyChip,
chipComponentPropsMapper: (company: PartialCompany): CompanyChipPropsType => {
return {
name: company.name,
picture: `https://www.google.com/s2/favicons?domain=${company.domain_name}&sz=256`,
};
},
changeHandler: (relation: PartialCompany) => {
console.log('changed', relation);
},
searchFilter: {
key: 'company_name',
label: 'Company',
icon: <FaBuilding />,
whereTemplate: () => {
return {};
},
searchQuery: SEARCH_COMPANY_QUERY,
searchTemplate: (searchInput: string) => ({
name: { _ilike: `%${searchInput}%` },
}),
searchResultMapper: (company: GraphqlQueryCompany) => ({
displayValue: company.name,
value: {
id: company.id,
name: company.name,
domain_name: company.domain_name,
},
}),
operands: [],
} satisfies FilterType<People_Bool_Exp>,
};

View File

@ -0,0 +1,58 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { EditableRelationStory } from '../__stories__/EditableRelation.stories';
import { CompanyChipPropsType } from '../../../chips/CompanyChip';
import { PartialCompany } from '../../../../interfaces/company.interface';
import { EditableRelationProps } from '../EditableRelation';
import { act } from 'react-dom/test-utils';
it('Checks the EditableRelation editing event bubbles up', async () => {
const func = jest.fn(() => null);
const { getByTestId, getByText } = render(
<EditableRelationStory
{...(EditableRelationStory.args as EditableRelationProps<
PartialCompany,
CompanyChipPropsType
>)}
changeHandler={func}
/>,
);
const parent = getByTestId('content-editable-parent');
const wrapper = parent.querySelector('div');
await waitFor(() => {
expect(getByText('Heroku')).toBeInTheDocument();
});
if (!wrapper) {
throw new Error('Editable relation not found');
}
fireEvent.click(wrapper);
const input = parent.querySelector('input');
if (!input) {
throw new Error('Search input not found');
}
act(() => {
fireEvent.change(input, { target: { value: 'Ai' } });
});
await waitFor(() => {
expect(getByText('Airbnb')).toBeInTheDocument();
});
act(() => {
fireEvent.click(getByText('Airbnb'));
});
await waitFor(() => {
expect(func).toBeCalledWith({
domain_name: 'abnb.com',
id: 'abnb',
name: 'Airbnb',
});
});
});

View File

@ -1,6 +1,6 @@
import { fireEvent, render } from '@testing-library/react';
import { RegularSortDropdownButton } from '../__stories__/SortDropdownButton.stories';
import { FaEnvelope } from 'react-icons/fa';
import { FaEnvelope, FaRegBuilding } from 'react-icons/fa';
it('Checks the default top option is Ascending', async () => {
const setSorts = jest.fn();
@ -49,3 +49,26 @@ it('Checks the selection of Descending', async () => {
_type: 'default_sort',
});
});
it('Checks custom_sort is working', async () => {
const setSorts = jest.fn();
const { getByText } = render(
<RegularSortDropdownButton setSorts={setSorts} />,
);
const sortDropdownButton = getByText('Sort');
fireEvent.click(sortDropdownButton);
const sortByCompany = getByText('Company');
fireEvent.click(sortByCompany);
expect(setSorts).toHaveBeenCalledWith(
expect.objectContaining({
key: 'company_name',
label: 'Company',
icon: <FaRegBuilding />,
_type: 'custom_sort',
order: 'asc',
}),
);
});

View File

@ -6,7 +6,7 @@ export interface Opportunity {
icon: string;
}
export interface Company {
export type Company = {
id: string;
name: string;
domain_name: string;
@ -15,7 +15,10 @@ export interface Company {
opportunities: Opportunity[];
accountOwner?: User;
creationDate: Date;
}
};
export type PartialCompany = Partial<Company> &
Pick<Company, 'id' | 'name' | 'domain_name'>;
export type GraphqlQueryCompany = {
id: string;

View File

@ -31,8 +31,12 @@ describe('mapPerson', () => {
city: '',
company: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: '',
name: 'Test',
domain_name: '',
opportunities: [],
employees: 0,
address: '',
creationDate: new Date(),
},
creationDate: new Date(),
pipe: {

View File

@ -1,4 +1,4 @@
import { Company } from './company.interface';
import { PartialCompany } from './company.interface';
import { Pipe } from './pipe.interface';
export type Person = {
@ -7,10 +7,7 @@ export type Person = {
lastname: string;
picture?: string;
email: string;
company: Omit<
Company,
'employees' | 'address' | 'opportunities' | 'accountOwner' | 'creationDate'
>;
company: PartialCompany;
phone: string;
creationDate: Date;
pipe: Pipe;

View File

@ -31,7 +31,7 @@ const StyledPeopleContainer = styled.div`
function People() {
const [orderBy, setOrderBy] = useState(defaultOrderBy);
const [where, setWhere] = useState<People_Bool_Exp>({});
const [filterSearchResults, setSearhInput, setFilterSearch] = useSearch();
const [filterSearchResults, setSearchInput, setFilterSearch] = useSearch();
const updateSorts = useCallback((sorts: Array<PeopleSelectedSortType>) => {
setOrderBy(sorts.length ? reduceSortsToOrderBy(sorts) : defaultOrderBy);
@ -61,7 +61,7 @@ function People() {
onSortsUpdate={updateSorts}
onFiltersUpdate={updateFilters}
onFilterSearch={(filter, searchValue) => {
setSearhInput(searchValue);
setSearchInput(searchValue);
setFilterSearch(filter);
}}
/>

View File

@ -1,12 +1,85 @@
import { render, waitFor } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { PeopleDefault } from '../__stories__/People.stories';
import { act } from 'react-dom/test-utils';
import {
GraphqlMutationPerson,
GraphqlQueryPerson,
} from '../../../interfaces/person.interface';
it('Checks the People page render', async () => {
const { getByTestId } = render(<PeopleDefault />);
jest.mock('../../../apollo', () => {
const personInterface = jest.requireActual(
'../../../interfaces/person.interface',
);
return {
apiClient: {
mutate: (arg: {
mutation: unknown;
variables: GraphqlMutationPerson;
}) => {
const gqlPerson = arg.variables as unknown as GraphqlQueryPerson;
return { data: personInterface.mapPerson(gqlPerson) };
},
},
};
});
it('Checks people full name edit is updating data', async () => {
const { getByText, getByDisplayValue } = render(<PeopleDefault />);
await waitFor(() => {
const personChip = getByTestId('row-id-0');
expect(personChip).toBeDefined();
expect(getByText('John Doe')).toBeDefined();
});
act(() => {
fireEvent.click(getByText('John Doe'));
});
await waitFor(() => {
expect(getByDisplayValue('John')).toBeInTheDocument();
});
act(() => {
const nameInput = getByDisplayValue('John');
if (!nameInput) {
throw new Error('firstNameInput is null');
}
fireEvent.change(nameInput, { target: { value: 'Jo' } });
fireEvent.click(getByText('All People')); // Click outside
});
await waitFor(() => {
expect(getByText('Jo Doe')).toBeInTheDocument();
});
});
it('Checks people email edit is updating data', async () => {
const { getByText, getByDisplayValue } = render(<PeopleDefault />);
await waitFor(() => {
expect(getByText('john@linkedin.com')).toBeDefined();
});
act(() => {
fireEvent.click(getByText('john@linkedin.com'));
});
await waitFor(() => {
expect(getByDisplayValue('john@linkedin.com')).toBeInTheDocument();
});
act(() => {
const emailInput = getByDisplayValue('john@linkedin.com');
if (!emailInput) {
throw new Error('emailInput is null');
}
fireEvent.change(emailInput, { target: { value: 'john@linkedin.c' } });
fireEvent.click(getByText('All People')); // Click outside
});
await waitFor(() => {
expect(getByText('john@linkedin.c')).toBeInTheDocument();
});
});

View File

@ -13,11 +13,12 @@ import { createColumnHelper } from '@tanstack/react-table';
import ClickableCell from '../../components/table/ClickableCell';
import ColumnHead from '../../components/table/ColumnHead';
import Checkbox from '../../components/form/Checkbox';
import CompanyChip from '../../components/chips/CompanyChip';
import CompanyChip, {
CompanyChipPropsType,
} from '../../components/chips/CompanyChip';
import { GraphqlQueryPerson, Person } from '../../interfaces/person.interface';
import PipeChip from '../../components/chips/PipeChip';
import EditableText from '../../components/table/editable-cell/EditableText';
import { updatePerson } from '../../services/people';
import {
FilterType,
SortType,
@ -31,10 +32,15 @@ import {
SEARCH_COMPANY_QUERY,
SEARCH_PEOPLE_QUERY,
} from '../../services/search/search';
import { GraphqlQueryCompany } from '../../interfaces/company.interface';
import {
GraphqlQueryCompany,
PartialCompany,
} from '../../interfaces/company.interface';
import EditablePhone from '../../components/table/editable-cell/EditablePhone';
import EditableFullName from '../../components/table/editable-cell/EditableFullName';
import EditableDate from '../../components/table/editable-cell/EditableDate';
import EditableRelation from '../../components/table/editable-cell/EditableRelation';
import { updatePerson } from '../../services/people';
export const availableSorts = [
{
@ -261,7 +267,7 @@ export const peopleColumns = [
const person = props.row.original;
person.firstname = firstName;
person.lastname = lastName;
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
updatePerson(person);
}}
/>
),
@ -275,7 +281,7 @@ export const peopleColumns = [
changeHandler={(value: string) => {
const person = props.row.original;
person.email = value;
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
updatePerson(person);
}}
/>
),
@ -285,12 +291,47 @@ export const peopleColumns = [
<ColumnHead viewName="Company" viewIcon={<FaRegBuilding />} />
),
cell: (props) => (
<ClickableCell href="#">
<CompanyChip
name={props.row.original.company.name}
picture={`https://www.google.com/s2/favicons?domain=${props.row.original.company.domain_name}&sz=256`}
<EditableRelation<PartialCompany, CompanyChipPropsType>
relation={props.row.original.company}
searchPlaceholder="Company"
ChipComponent={CompanyChip}
chipComponentPropsMapper={(
company: PartialCompany,
): CompanyChipPropsType => {
return {
name: company.name,
picture: `https://www.google.com/s2/favicons?domain=${company.domain_name}&sz=256`,
};
}}
changeHandler={(relation: PartialCompany) => {
const person = props.row.original;
person.company.id = relation.id;
updatePerson(person);
}}
searchFilter={
{
key: 'company_name',
label: 'Company',
icon: <FaBuilding />,
whereTemplate: () => {
return {};
},
searchQuery: SEARCH_COMPANY_QUERY,
searchTemplate: (searchInput: string) => ({
name: { _ilike: `%${searchInput}%` },
}),
searchResultMapper: (company: GraphqlQueryCompany) => ({
displayValue: company.name,
value: {
id: company.id,
name: company.name,
domain_name: company.domain_name,
},
}),
operands: [],
} satisfies FilterType<People_Bool_Exp>
}
/>
</ClickableCell>
),
}),
columnHelper.accessor('phone', {
@ -302,7 +343,7 @@ export const peopleColumns = [
changeHandler={(value: string) => {
const person = props.row.original;
person.phone = value;
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
updatePerson(person);
}}
/>
),
@ -315,7 +356,7 @@ export const peopleColumns = [
changeHandler={(value: Date) => {
const person = props.row.original;
person.creationDate = value;
updatePerson(person).catch((error) => console.error(error)); // TODO: handle error
updatePerson(person);
}}
/>
),
@ -332,13 +373,13 @@ export const peopleColumns = [
header: () => <ColumnHead viewName="City" viewIcon={<FaMapPin />} />,
cell: (props) => (
<EditableText
shouldAlignRight={true}
editModeHorizontalAlign="right"
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
updatePerson(person);
}}
/>
),

View File

@ -23,7 +23,8 @@ jest.mock('../../../apollo', () => {
it('updates a person', async () => {
const result = await updatePerson({
fullName: 'John Doe',
firstname: 'John',
lastname: 'Doe',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c',
email: 'john@example.com',
company: {