mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-19 01:21:30 +03:00
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:
parent
7ac2f8e1a6
commit
41c46c36ed
@ -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 && (
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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'));
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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%;
|
||||
|
||||
display: flex;
|
||||
padding-left: ${props.theme.spacing(2)};
|
||||
padding-right: ${props.theme.spacing(2)};
|
||||
background: ${props.theme.primaryBackground};
|
||||
border: 1px solid ${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>`
|
||||
const StyledNonEditModeContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-left: ${(props) => props.theme.spacing(2)};
|
||||
padding-right: ${(props) => props.theme.spacing(2)};
|
||||
${(props) => props.isEditMode && styledEditModeWrapper(props)}
|
||||
`;
|
||||
|
||||
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({
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -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 ? (
|
||||
onOutsideClick={() => setIsEditMode(false)}
|
||||
onInsideClick={() => setIsEditMode(true)}
|
||||
isEditMode={isEditMode}
|
||||
editModeHorizontalAlign={editModeHorizontalAlign}
|
||||
editModeContent={
|
||||
<StyledInplaceInput
|
||||
isEditMode={isEditMode}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
>
|
||||
<StyledContainer>
|
||||
<DatePicker
|
||||
isOpen={isEditMode}
|
||||
date={inputValue}
|
||||
onChangeHandler={(date: Date) => {
|
||||
changeHandler(date);
|
||||
setInputValue(date);
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
</EditableCellWrapper>
|
||||
isEditMode={isEditMode}
|
||||
onOutsideClick={() => setIsEditMode(false)}
|
||||
onInsideClick={() => setIsEditMode(true)}
|
||||
editModeHorizontalAlign={editModeHorizontalAlign}
|
||||
editModeContent={
|
||||
<StyledContainer>
|
||||
<DatePicker
|
||||
date={inputValue}
|
||||
onChangeHandler={(date: Date) => {
|
||||
changeHandler(date);
|
||||
setInputValue(date);
|
||||
}}
|
||||
customInput={<DateDisplay />}
|
||||
/>
|
||||
</StyledContainer>
|
||||
}
|
||||
nonEditModeContent={
|
||||
<StyledContainer>
|
||||
<div>
|
||||
{inputValue &&
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}).format(inputValue)}
|
||||
</div>
|
||||
</StyledContainer>
|
||||
}
|
||||
></EditableCellWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
135
front/src/components/table/editable-cell/EditableRelation.tsx
Normal file
135
front/src/components/table/editable-cell/EditableRelation.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>,
|
||||
};
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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`}
|
||||
/>
|
||||
</ClickableCell>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
@ -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: {
|
||||
|
Loading…
Reference in New Issue
Block a user