Reorganize frontend and install Craco to alias modules (#190)

This commit is contained in:
Charles Bochet
2023-06-04 11:23:09 +02:00
committed by GitHub
parent bbc80cd543
commit 7b858fd7c9
149 changed files with 3441 additions and 1158 deletions

View File

@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { hasAccessToken } from '../services/AuthService';
export function RequireAuth({
children,
}: {
children: JSX.Element;
}): JSX.Element {
const navigate = useNavigate();
useEffect(() => {
if (!hasAccessToken()) {
navigate('/auth/login');
}
}, [navigate]);
return children;
}

View File

@ -0,0 +1,52 @@
import jwt from 'jwt-decode';
export const hasAccessToken = () => {
const accessToken = localStorage.getItem('accessToken');
return accessToken ? true : false;
};
export const getUserIdFromToken: () => string | null = () => {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
return null;
}
try {
return jwt<{ userId: string }>(accessToken).userId;
} catch (error) {
return null;
}
};
export const hasRefreshToken = () => {
const refreshToken = localStorage.getItem('refreshToken');
return refreshToken ? true : false;
};
export const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
localStorage.removeItem('accessToken');
}
const response = await fetch(
process.env.REACT_APP_AUTH_URL + '/token' || '',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
},
);
if (response.ok) {
const { accessToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
} else {
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
}
};

View File

@ -0,0 +1,90 @@
import { waitFor } from '@testing-library/react';
import {
getUserIdFromToken,
hasAccessToken,
hasRefreshToken,
refreshAccessToken,
} from '../AuthService';
const mockFetch = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const refreshToken = init?.body
? JSON.parse(init.body.toString()).refreshToken
: null;
return new Promise((resolve) => {
resolve(
new Response(
JSON.stringify({
accessToken:
refreshToken === 'xxx-valid-refresh' ? 'xxx-valid-access' : null,
}),
),
);
});
};
global.fetch = mockFetch;
it('hasAccessToken is true when token is present', () => {
localStorage.setItem('accessToken', 'xxx');
expect(hasAccessToken()).toBe(true);
});
it('hasAccessToken is false when token is not', () => {
expect(hasAccessToken()).toBe(false);
});
it('hasRefreshToken is true when token is present', () => {
localStorage.setItem('refreshToken', 'xxx');
expect(hasRefreshToken()).toBe(true);
});
it('hasRefreshToken is true when token is not', () => {
expect(hasRefreshToken()).toBe(false);
});
it('refreshToken does not refresh the token if refresh token is missing', () => {
refreshAccessToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken does not refreh the token if refresh token is invalid', () => {
localStorage.setItem('refreshToken', 'xxx-invalid-refresh');
refreshAccessToken();
expect(localStorage.getItem('accessToken')).toBeNull();
});
it('refreshToken refreshes the token if refresh token is valid', async () => {
localStorage.setItem('refreshToken', 'xxx-valid-refresh');
refreshAccessToken();
await waitFor(() => {
expect(localStorage.getItem('accessToken')).toBe('xxx-valid-access');
});
});
it('getUserIdFromToken returns null when the token is not present', async () => {
const userId = getUserIdFromToken();
expect(userId).toBeNull();
});
it('getUserIdFromToken returns null when the token is not valid', async () => {
localStorage.setItem('accessToken', 'xxx-invalid-access');
const userId = getUserIdFromToken();
expect(userId).toBeNull();
});
it('getUserIdFromToken returns the right userId when the token is valid', async () => {
localStorage.setItem(
'accessToken',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJiNzU5MGRiOS1hYzdkLTQyNzUtOWM2Yy0zMjM5NzkxOTI3OTUiLCJ3b3Jrc3BhY2VJZCI6IjdlZDlkMjEyLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsImlhdCI6MTY4NTA5MzE3MiwiZXhwIjoxNjg1MDkzNDcyfQ.0g-z2vKBbGGcs0EmZ3Q7HpZ9Yno_SOrprhcQMm1Zb6Y',
);
const userId = getUserIdFromToken();
expect(userId).toBe('b7590db9-ac7d-4275-9c6c-323979192795');
});
afterEach(() => {
localStorage.clear();
});

View File

@ -0,0 +1,19 @@
import styled from '@emotion/styled';
import { CommentChip, CommentChipProps } from './CommentChip';
const StyledCellWrapper = styled.div`
position: relative;
right: 38px;
top: -13px;
width: 0;
height: 0;
`;
export function CellCommentChip(props: CommentChipProps) {
return (
<StyledCellWrapper>
<CommentChip {...props} />
</StyledCellWrapper>
);
}

View File

@ -0,0 +1,58 @@
import styled from '@emotion/styled';
import { IconComment } from '@/ui/icons';
export type CommentChipProps = {
count: number;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
const StyledChip = styled.div`
height: 26px;
min-width: 34px;
padding-left: 2px;
padding-right: 2px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 2px;
background: ${(props) => props.theme.secondaryBackgroundTransparent};
backdrop-filter: blur(6px);
border-radius: ${(props) => props.theme.borderRadius};
cursor: pointer;
color: ${(props) => props.theme.text30};
&:hover {
background: ${(props) => props.theme.tertiaryBackground};
color: ${(props) => props.theme.text40};
}
user-select: none;
`;
const StyledCount = styled.div`
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 500;
`;
export function CommentChip({ count, onClick }: CommentChipProps) {
const formattedCount = count > 99 ? '99+' : count;
return (
<StyledChip onClick={onClick}>
<StyledCount>{formattedCount}</StyledCount>
<IconComment size={12} />
</StyledChip>
);
}

View File

@ -0,0 +1,123 @@
import { useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { HotkeysEvent } from 'react-hotkeys-hook/dist/types';
import { HiArrowSmRight } from 'react-icons/hi';
import TextareaAutosize from 'react-textarea-autosize';
import styled from '@emotion/styled';
import { IconButton } from '@/ui/components/buttons/IconButton';
type OwnProps = {
onSend?: (text: string) => void;
placeholder?: string;
};
const StyledContainer = styled.div`
display: flex;
min-height: 32px;
width: 100%;
`;
const StyledTextArea = styled(TextareaAutosize)`
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
font-weight: 400;
line-height: 16px;
border: none;
border-radius: 5px;
background: ${(props) => props.theme.tertiaryBackground};
color: ${(props) => props.theme.text80};
overflow: auto;
resize: none;
&:focus {
outline: none;
border: none;
}
&::placeholder {
color: ${(props) => props.theme.text30};
font-weight: 400;
}
`;
const StyledBottomRightIconButton = styled.div`
width: 0px;
position: relative;
top: calc(100% - 26.5px);
right: 26px;
`;
export function CommentTextInput({ placeholder, onSend }: OwnProps) {
const [text, setText] = useState('');
const isSendButtonDisabled = !text;
useHotkeys(
['shift+enter', 'enter'],
(event: KeyboardEvent, handler: HotkeysEvent) => {
if (handler.shift) {
return;
} else {
event.preventDefault();
onSend?.(text);
setText('');
}
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[onSend],
);
useHotkeys(
'esc',
(event: KeyboardEvent, handler: HotkeysEvent) => {
event.preventDefault();
setText('');
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
[onSend],
);
function handleInputChange(event: React.FormEvent<HTMLTextAreaElement>) {
const newText = event.currentTarget.value;
setText(newText);
}
function handleOnClickSendButton() {
onSend?.(text);
setText('');
}
return (
<>
<StyledContainer>
<StyledTextArea
placeholder={placeholder || 'Write something...'}
maxRows={5}
onChange={handleInputChange}
value={text}
/>
<StyledBottomRightIconButton>
<IconButton
onClick={handleOnClickSendButton}
icon={<HiArrowSmRight size={15} />}
disabled={isSendButtonDisabled}
/>
</StyledBottomRightIconButton>
</StyledContainer>
</>
);
}

View File

@ -0,0 +1,31 @@
import { useRecoilState } from 'recoil';
import { RightDrawerBody } from '@/ui/layout/right-drawer/components/RightDrawerBody';
import { RightDrawerPage } from '@/ui/layout/right-drawer/components/RightDrawerPage';
import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
import { commentableEntityArrayState } from '../../states/commentableEntityArrayState';
import { CommentTextInput } from './CommentTextInput';
export function RightDrawerComments() {
const [commentableEntityArray] = useRecoilState(commentableEntityArrayState);
function handleSendComment(text: string) {
console.log(text);
}
return (
<RightDrawerPage>
<RightDrawerTopBar title="Comments" />
<RightDrawerBody>
{commentableEntityArray.map((commentableEntity) => (
<div key={commentableEntity.id}>
{commentableEntity.type} - {commentableEntity.id}
</div>
))}
<CommentTextInput onSend={handleSendComment} />
</RightDrawerBody>
</RightDrawerPage>
);
}

View File

@ -0,0 +1,68 @@
import styled from '@emotion/styled';
import type { Meta, StoryObj } from '@storybook/react';
import { CellBaseContainer } from '@/ui/components/editable-cell/CellBaseContainer';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CellCommentChip } from '../CellCommentChip';
import { CommentChip } from '../CommentChip';
const meta: Meta<typeof CellCommentChip> = {
title: 'Components/CellCommentChip',
component: CellCommentChip,
};
export default meta;
type Story = StoryObj<typeof CellCommentChip>;
const TestCellContainer = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 250px;
height: fit-content;
background: ${(props) => props.theme.primaryBackground};
`;
const StyledFakeCellText = styled.div`
display: flex;
width: 100%;
`;
export const OneComment: Story = {
render: getRenderWrapperForComponent(<CommentChip count={1} />),
};
export const TenComments: Story = {
render: getRenderWrapperForComponent(<CommentChip count={10} />),
};
export const TooManyComments: Story = {
render: getRenderWrapperForComponent(<CommentChip count={1000} />),
};
export const InCellDefault: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<CellBaseContainer>
<StyledFakeCellText>Fake short text</StyledFakeCellText>
<CellCommentChip count={12} />
</CellBaseContainer>
</TestCellContainer>,
),
};
export const InCellOverlappingBlur: Story = {
render: getRenderWrapperForComponent(
<TestCellContainer>
<CellBaseContainer>
<StyledFakeCellText>
Fake long text to demonstrate blur effect
</StyledFakeCellText>
<CellCommentChip count={12} />
</CellBaseContainer>
</TestCellContainer>,
),
};

View File

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
import { CommentTextInput } from '../CommentTextInput';
const meta: Meta<typeof CommentTextInput> = {
title: 'Components/CommentTextInput',
component: CommentTextInput,
argTypes: {
onSend: {
action: 'onSend',
},
},
};
export default meta;
type Story = StoryObj<typeof CommentTextInput>;
export const Default: Story = {
render: getRenderWrapperForComponent(<CommentTextInput />),
parameters: {
msw: graphqlMocks,
actions: { argTypesRegex: '^on.*' },
},
args: {
onSend: (text: string) => {
console.log(text);
},
},
};

View File

@ -1,7 +1,8 @@
import { useRecoilState } from 'recoil';
import { useOpenRightDrawer } from '../../ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { CommentableEntity } from '../types/CommentableEntity';
import { commentableEntityArrayState } from '../states/commentableEntityArrayState';
import { CommentableEntity } from '../types/CommentableEntity';
export function useOpenCommentRightDrawer() {
const openRightDrawer = useOpenRightDrawer();

View File

@ -1,4 +1,5 @@
import { atom } from 'recoil';
import { CommentableEntity } from '../types/CommentableEntity';
export const commentableEntityArrayState = atom<CommentableEntity[]>({

View File

@ -1,4 +1,4 @@
import { CommentableType } from '../../../generated/graphql';
import { CommentableType } from '~/generated/graphql';
export type CommentableEntity = {
type: keyof typeof CommentableType;

View File

@ -0,0 +1,44 @@
import * as React from 'react';
import styled from '@emotion/styled';
export type CompanyChipPropsType = {
name: string;
picture?: string;
};
const StyledContainer = styled.span`
background-color: ${(props) => props.theme.tertiaryBackground};
border-radius: ${(props) => props.theme.spacing(1)};
color: ${(props) => props.theme.text80};
display: inline-flex;
align-items: center;
padding: ${(props) => props.theme.spacing(1)};
gap: ${(props) => props.theme.spacing(1)};
:hover {
filter: brightness(95%);
}
img {
height: 14px;
width: 14px;
object-fit: cover;
}
`;
function CompanyChip({ name, picture }: CompanyChipPropsType) {
return (
<StyledContainer data-testid="company-chip">
{picture && (
<img
data-testid="company-chip-image"
src={picture?.toString()}
alt={`${name}-company-logo`}
/>
)}
{name}
</StyledContainer>
);
}
export default CompanyChip;

View File

@ -0,0 +1,42 @@
import { useOpenCommentRightDrawer } from '@/comments/hooks/useOpenCommentRightDrawer';
import EditableChip from '@/ui/components/editable-cell/EditableChip';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import { Company } from '../interfaces/company.interface';
import { updateCompany } from '../services';
import CompanyChip from './CompanyChip';
type OwnProps = {
company: Company;
};
export function CompanyEditableNameChipCell({ company }: OwnProps) {
const openCommentRightDrawer = useOpenCommentRightDrawer();
function handleCommentClick() {
openCommentRightDrawer([
{
type: 'Company',
id: company.id,
},
]);
}
return (
<EditableChip
value={company.name || ''}
placeholder="Name"
picture={getLogoUrlFromDomainName(company.domainName)}
changeHandler={(value: string) => {
updateCompany({
...company,
name: value,
});
}}
ChipComponent={CompanyChip}
commentCount={12}
onCommentClick={handleCommentClick}
/>
);
}

View File

@ -0,0 +1,88 @@
import {
Company,
GraphqlMutationCompany,
GraphqlQueryCompany,
mapToCompany,
mapToGqlCompany,
} from '../company.interface';
describe('Company mappers', () => {
it('should map GraphQl Company to Company', () => {
const now = new Date();
now.setMilliseconds(0);
const graphQLCompany = {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: 'ACME',
domainName: 'exmaple.com',
createdAt: now.toUTCString(),
employees: 10,
address: '1 Infinite Loop, 95014 Cupertino, California, USA',
accountOwner: {
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
email: 'john@example.com',
displayName: 'John Doe',
__typename: 'User',
},
pipes: [
{
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c',
name: 'Pipe 1',
icon: '!',
__typename: 'Pipe',
},
],
__typename: 'companies',
} satisfies GraphqlQueryCompany;
const company = mapToCompany(graphQLCompany);
expect(company).toStrictEqual({
__typename: 'companies',
id: graphQLCompany.id,
name: graphQLCompany.name,
domainName: graphQLCompany.domainName,
createdAt: new Date(now.toUTCString()),
employees: graphQLCompany.employees,
address: graphQLCompany.address,
accountOwner: {
__typename: 'users',
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
email: 'john@example.com',
displayName: 'John Doe',
workspaceMember: undefined,
},
pipes: [],
} satisfies Company);
});
it('should map Company to GraphQLCompany', () => {
const now = new Date();
now.setMilliseconds(0);
const company = {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: 'ACME',
domainName: 'example.com',
employees: 10,
address: '1 Infinite Loop, 95014 Cupertino, California, USA',
pipes: [],
accountOwner: {
id: '522d4ec4-c46b-4360-a0a7-df8df170be81',
email: 'john@example.com',
displayName: 'John Doe',
__typename: 'users',
},
createdAt: now,
__typename: 'companies',
} satisfies Company;
const graphQLCompany = mapToGqlCompany(company);
expect(graphQLCompany).toStrictEqual({
id: company.id,
name: company.name,
domainName: company.domainName,
createdAt: now.toUTCString(),
employees: company.employees,
address: company.address,
accountOwnerId: '522d4ec4-c46b-4360-a0a7-df8df170be81',
__typename: 'companies',
} satisfies GraphqlMutationCompany);
});
});

View File

@ -0,0 +1,78 @@
import {
GraphqlQueryPipeline,
Pipeline,
} from '../../pipelines/interfaces/pipeline.interface';
import {
GraphqlQueryUser,
mapToUser,
User,
} from '../../users/interfaces/user.interface';
export type Company = {
__typename: 'companies';
id: string;
name?: string;
domainName?: string;
employees?: number | null;
address?: string;
createdAt?: Date;
pipes?: Pipeline[];
accountOwner?: User | null;
};
export type GraphqlQueryCompany = {
id: string;
name?: string;
domainName?: string;
employees?: number | null;
address?: string;
createdAt?: string;
accountOwner?: GraphqlQueryUser | null;
pipes?: GraphqlQueryPipeline[] | null;
__typename: string;
};
export type GraphqlMutationCompany = {
id: string;
name?: string;
domainName?: string;
employees?: number | null;
address?: string;
createdAt?: string;
accountOwnerId?: string;
__typename: string;
};
export const mapToCompany = (company: GraphqlQueryCompany): Company => ({
__typename: 'companies',
id: company.id,
employees: company.employees,
name: company.name,
address: company.address,
domainName: company.domainName,
createdAt: company.createdAt ? new Date(company.createdAt) : undefined,
accountOwner: company.accountOwner
? mapToUser(company.accountOwner)
: company.accountOwner,
pipes: [],
});
export const mapToGqlCompany = (company: Company): GraphqlMutationCompany => ({
id: company.id,
name: company.name,
domainName: company.domainName,
address: company.address,
employees: company.employees,
createdAt: company.createdAt ? company.createdAt.toUTCString() : undefined,
accountOwnerId: company.accountOwner?.id,
__typename: 'companies',
});

View File

@ -0,0 +1,19 @@
import { reduceSortsToOrderBy } from '@/filters-and-sorts/helpers';
import { CompaniesSelectedSortType } from '../select';
describe('reduceSortsToOrderBy', () => {
it('should return an array of objects with the id as key and the order as value', () => {
const sorts = [
{ key: 'name', label: 'name', order: 'asc', _type: 'default_sort' },
{
key: 'domainName',
label: 'domainName',
order: 'desc',
_type: 'default_sort',
},
] satisfies CompaniesSelectedSortType[];
const result = reduceSortsToOrderBy(sorts);
expect(result).toEqual([{ name: 'asc' }, { domainName: 'desc' }]);
});
});

View File

@ -0,0 +1,2 @@
export * from './select';
export * from './update';

View File

@ -0,0 +1,48 @@
import { gql, QueryResult, useQuery } from '@apollo/client';
import { SelectedSortType } from '@/filters-and-sorts/interfaces/sorts/interface';
import {
CompanyOrderByWithRelationInput as Companies_Order_By,
CompanyWhereInput as Companies_Bool_Exp,
SortOrder as Order_By,
} from '~/generated/graphql';
import { GraphqlQueryCompany } from '../interfaces/company.interface';
export type CompaniesSelectedSortType = SelectedSortType<Companies_Order_By>;
export const GET_COMPANIES = gql`
query GetCompanies(
$orderBy: [CompanyOrderByWithRelationInput!]
$where: CompanyWhereInput
) {
companies: findManyCompany(orderBy: $orderBy, where: $where) {
id
domainName
name
createdAt
address
employees
accountOwner {
id
email
displayName
}
}
}
`;
export function useCompaniesQuery(
orderBy: Companies_Order_By[],
where: Companies_Bool_Exp,
): QueryResult<{ companies: GraphqlQueryCompany[] }> {
return useQuery<{ companies: GraphqlQueryCompany[] }>(GET_COMPANIES, {
variables: { orderBy, where },
});
}
export const defaultOrderBy: Companies_Order_By[] = [
{
createdAt: Order_By.Desc,
},
];

View File

@ -0,0 +1,111 @@
import { FetchResult, gql } from '@apollo/client';
import { apiClient } from '~/apollo';
import { Company, mapToGqlCompany } from '../interfaces/company.interface';
export const UPDATE_COMPANY = gql`
mutation UpdateCompany(
$id: String
$name: String
$domainName: String
$accountOwnerId: String
$createdAt: DateTime
$address: String
$employees: Int
) {
updateOneCompany(
where: { id: $id }
data: {
accountOwner: { connect: { id: $accountOwnerId } }
address: { set: $address }
domainName: { set: $domainName }
employees: { set: $employees }
name: { set: $name }
createdAt: { set: $createdAt }
}
) {
accountOwner {
id
email
displayName
}
address
createdAt
domainName
employees
id
name
}
}
`;
export const INSERT_COMPANY = gql`
mutation InsertCompany(
$id: String!
$name: String!
$domainName: String!
$createdAt: DateTime
$address: String!
$employees: Int
) {
createOneCompany(
data: {
id: $id
name: $name
domainName: $domainName
createdAt: $createdAt
address: $address
employees: $employees
}
) {
address
createdAt
domainName
employees
id
name
}
}
`;
export const DELETE_COMPANIES = gql`
mutation DeleteCompanies($ids: [String!]) {
deleteManyCompany(where: { id: { in: $ids } }) {
count
}
}
`;
export async function updateCompany(
company: Company,
): Promise<FetchResult<Company>> {
const result = await apiClient.mutate({
mutation: UPDATE_COMPANY,
variables: mapToGqlCompany(company),
});
return result;
}
export async function insertCompany(
company: Company,
): Promise<FetchResult<Company>> {
const result = await apiClient.mutate({
mutation: INSERT_COMPANY,
variables: mapToGqlCompany(company),
refetchQueries: ['GetCompanies'],
});
return result;
}
export async function deleteCompanies(
peopleIds: string[],
): Promise<FetchResult<Company>> {
const result = await apiClient.mutate({
mutation: DELETE_COMPANIES,
variables: { ids: peopleIds },
});
return result;
}

View File

@ -0,0 +1,47 @@
import { SortOrder as Order_By } from '~/generated/graphql';
import { BoolExpType } from '../utils/interfaces/generic.interface';
import {
FilterableFieldsType,
FilterWhereType,
SelectedFilterType,
} from './interfaces/filters/interface';
import { SelectedSortType } from './interfaces/sorts/interface';
export const reduceFiltersToWhere = <
ValueType extends FilterableFieldsType,
WhereTemplateType extends FilterWhereType,
>(
filters: Array<SelectedFilterType<ValueType, WhereTemplateType>>,
): BoolExpType<WhereTemplateType> => {
const where = filters.reduce((acc, filter) => {
return { ...acc, ...filter.operand.whereTemplate(filter.value) };
}, {} as BoolExpType<WhereTemplateType>);
return where;
};
const mapOrderToOrder_By = (order: string) => {
if (order === 'asc') return Order_By.Asc;
return Order_By.Desc;
};
export const defaultOrderByTemplateFactory =
(key: string) => (order: string) => ({
[key]: order,
});
export const reduceSortsToOrderBy = <OrderByTemplate>(
sorts: Array<SelectedSortType<OrderByTemplate>>,
): OrderByTemplate[] => {
const mappedSorts = sorts.map((sort) => {
if (sort._type === 'custom_sort') {
return sort.orderByTemplates.map((orderByTemplate) =>
orderByTemplate(mapOrderToOrder_By(sort.order)),
);
}
return defaultOrderByTemplateFactory(sort.key as string)(sort.order);
});
return mappedSorts.flat() as OrderByTemplate[];
};

View File

@ -0,0 +1,80 @@
import { ReactNode } from 'react';
import { SearchConfigType } from '@/search/interfaces/interface';
import {
AnyEntity,
BoolExpType,
UnknownType,
} from '@/utils/interfaces/generic.interface';
export type FilterableFieldsType = AnyEntity;
export type FilterWhereRelationType = AnyEntity;
export type FilterWhereType = FilterWhereRelationType | string | UnknownType;
export type FilterConfigType<
FilteredType extends FilterableFieldsType,
WhereType extends FilterWhereType = UnknownType,
> = {
key: string;
label: string;
icon: ReactNode;
type: WhereType extends UnknownType
? 'relation' | 'text' | 'date'
: WhereType extends AnyEntity
? 'relation'
: WhereType extends string
? 'text' | 'date'
: never;
operands: FilterOperandType<FilteredType, WhereType>[];
} & (WhereType extends UnknownType
? { searchConfig?: SearchConfigType<UnknownType> }
: WhereType extends AnyEntity
? { searchConfig: SearchConfigType<WhereType> }
: WhereType extends string
? object
: never) &
(WhereType extends UnknownType
? { selectedValueRender?: (selected: any) => string }
: WhereType extends AnyEntity
? { selectedValueRender: (selected: WhereType) => string }
: WhereType extends string
? object
: never);
export type FilterOperandType<
FilteredType extends FilterableFieldsType,
WhereType extends FilterWhereType = UnknownType,
> = WhereType extends UnknownType
? any
: WhereType extends FilterWhereRelationType
? FilterOperandRelationType<FilteredType, WhereType>
: WhereType extends string
? FilterOperandFieldType<FilteredType>
: never;
type FilterOperandRelationType<
FilteredType extends FilterableFieldsType,
WhereType extends FilterWhereType,
> = {
label: 'Is' | 'Is not';
id: 'is' | 'is_not';
whereTemplate: (value: WhereType) => BoolExpType<FilteredType>;
};
type FilterOperandFieldType<FilteredType extends FilterableFieldsType> = {
label: 'Contains' | 'Does not contain' | 'Greater than' | 'Less than';
id: 'like' | 'not_like' | 'greater_than' | 'less_than';
whereTemplate: (value: string) => BoolExpType<FilteredType>;
};
export type SelectedFilterType<
FilteredType extends FilterableFieldsType,
WhereType extends FilterWhereType = UnknownType,
> = {
key: string;
value: WhereType extends UnknownType ? any : WhereType;
displayValue: string;
label: string;
icon: ReactNode;
operand: FilterOperandType<FilteredType, WhereType>;
};

View File

@ -0,0 +1,22 @@
import { ReactNode } from 'react';
import { SortOrder as Order_By } from '~/generated/graphql';
export type SortType<OrderByTemplate> =
| {
_type: 'default_sort';
label: string;
key: keyof OrderByTemplate & string;
icon?: ReactNode;
}
| {
_type: 'custom_sort';
label: string;
key: string;
icon?: ReactNode;
orderByTemplates: Array<(order: Order_By) => OrderByTemplate>;
};
export type SelectedSortType<OrderByTemplate> = SortType<OrderByTemplate> & {
order: 'asc' | 'desc';
};

View File

@ -0,0 +1,63 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip';
import { EditableDoubleText } from '@/ui/components/editable-cell/EditableDoubleText';
import { PersonChip } from './PersonChip';
type OwnProps = {
firstname: string;
lastname: string;
onChange: (firstname: string, lastname: string) => void;
};
const StyledDiv = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
`;
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(firstValue, secondValue);
}
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
console.log('comment clicked');
}
return (
<EditableDoubleText
firstValue={firstnameValue}
secondValue={lastnameValue}
firstValuePlaceholder="First name"
secondValuePlaceholder="Last name"
onChange={handleDoubleTextChange}
nonEditModeContent={
<>
<StyledDiv>
<PersonChip name={firstname + ' ' + lastname} />
</StyledDiv>
<CellCommentChip count={12} onClick={handleCommentClick} />
</>
}
/>
);
}

View File

@ -0,0 +1,113 @@
import { useState } from 'react';
import { v4 } from 'uuid';
import CompanyChip, {
CompanyChipPropsType,
} from '@/companies/components/CompanyChip';
import {
Company,
mapToCompany,
} from '@/companies/interfaces/company.interface';
import { SearchConfigType } from '@/search/interfaces/interface';
import { SEARCH_COMPANY_QUERY } from '@/search/services/search';
import { EditableRelation } from '@/ui/components/editable-cell/EditableRelation';
import { getLogoUrlFromDomainName } from '@/utils/utils';
import {
QueryMode,
useInsertCompanyMutation,
useUpdatePeopleMutation,
} from '~/generated/graphql';
import { mapToGqlPerson, Person } from '../interfaces/person.interface';
import { PeopleCompanyCreateCell } from './PeopleCompanyCreateCell';
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,
domainName: companyDomainName,
address: '',
createdAt: new Date().toISOString(),
},
});
await updatePeople({
variables: {
...mapToGqlPerson(people),
companyId: 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: getLogoUrlFromDomainName(company.domainName),
};
}}
onChange={async (relation) => {
await updatePeople({
variables: {
...mapToGqlPerson(people),
companyId: relation.id,
},
});
}}
onChangeSearchInput={handleChangeSearchInput}
searchConfig={
{
query: SEARCH_COMPANY_QUERY,
template: (searchInput: string) => ({
name: { contains: `%${searchInput}%`, mode: QueryMode.Insensitive },
}),
resultMapper: (company) => ({
render: (company) => company.name,
value: mapToCompany(company),
}),
} satisfies SearchConfigType<Company>
}
onCreate={() => {
setIsCreating(true);
}}
/>
);
}

View File

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

View File

@ -0,0 +1,46 @@
import * as React from 'react';
import styled from '@emotion/styled';
import PersonPlaceholder from './person-placeholder.png';
export type PersonChipPropsType = {
name: string;
picture?: string;
};
const StyledContainer = styled.span`
background-color: ${(props) => props.theme.tertiaryBackground};
border-radius: ${(props) => props.theme.spacing(1)};
color: ${(props) => props.theme.text80};
display: inline-flex;
align-items: center;
padding: ${(props) => props.theme.spacing(1)};
gap: ${(props) => props.theme.spacing(1)};
overflow: hidden;
white-space: nowrap;
:hover {
filter: brightness(95%);
}
img {
height: 14px;
width: 14px;
border-radius: 100%;
object-fit: cover;
}
`;
export function PersonChip({ name, picture }: PersonChipPropsType) {
return (
<StyledContainer data-testid="person-chip">
<img
data-testid="person-chip-image"
src={picture ? picture.toString() : PersonPlaceholder.toString()}
alt="person"
/>
{name}
</StyledContainer>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,82 @@
import {
GraphqlMutationPerson,
GraphqlQueryPerson,
mapToGqlPerson,
mapToPerson,
Person,
} from '../person.interface';
describe('Person mappers', () => {
it('should map GraphqlPerson to Person', () => {
const now = new Date();
now.setMilliseconds(0);
const graphQLPerson = {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
firstname: 'John',
lastname: 'Doe',
createdAt: now.toUTCString(),
email: 'john.doe@gmail.com',
phone: '+1 (555) 123-4567',
city: 'Paris',
company: {
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
name: 'John Doe',
__typename: 'Company',
},
__typename: 'people',
} satisfies GraphqlQueryPerson;
const person = mapToPerson(graphQLPerson);
expect(person).toStrictEqual({
__typename: 'people',
id: graphQLPerson.id,
firstname: graphQLPerson.firstname,
lastname: graphQLPerson.lastname,
createdAt: new Date(now.toUTCString()),
email: graphQLPerson.email,
city: graphQLPerson.city,
phone: graphQLPerson.phone,
company: {
__typename: 'companies',
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
accountOwner: undefined,
address: undefined,
createdAt: undefined,
domainName: undefined,
employees: undefined,
name: 'John Doe',
pipes: [],
},
} satisfies Person);
});
it('should map Person to GraphQlPerson', () => {
const now = new Date();
now.setMilliseconds(0);
const person = {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
firstname: 'John',
lastname: 'Doe',
createdAt: new Date(now.toUTCString()),
email: 'john.doe@gmail.com',
phone: '+1 (555) 123-4567',
city: 'Paris',
company: {
id: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
},
} satisfies Person;
const graphQLPerson = mapToGqlPerson(person);
expect(graphQLPerson).toStrictEqual({
id: person.id,
firstname: person.firstname,
lastname: person.lastname,
createdAt: now.toUTCString(),
email: person.email,
city: person.city,
phone: person.phone,
companyId: '7af20dea-0412-4c4c-8b13-d6f0e6e09e87',
__typename: 'people',
} satisfies GraphqlMutationPerson);
});
});

View File

@ -0,0 +1,77 @@
import {
Company,
GraphqlQueryCompany,
mapToCompany,
} from '@/companies/interfaces/company.interface';
import { Pipeline } from '@/pipelines/interfaces/pipeline.interface';
export type Person = {
__typename: 'people';
id: string;
firstname?: string;
lastname?: string;
picture?: string | null;
email?: string;
phone?: string;
city?: string;
createdAt?: Date;
company?: Company | null;
pipes?: Pipeline[] | null;
};
export type GraphqlQueryPerson = {
id: string;
firstname?: string;
lastname?: string;
city?: string;
email?: string;
phone?: string;
createdAt?: string;
company?: GraphqlQueryCompany | null;
__typename: string;
};
export type GraphqlMutationPerson = {
id: string;
firstname?: string;
lastname?: string;
email?: string;
phone?: string;
city?: string;
createdAt?: string;
companyId?: string;
__typename: 'people';
};
export const mapToPerson = (person: GraphqlQueryPerson): Person => ({
__typename: 'people',
id: person.id,
firstname: person.firstname,
lastname: person.lastname,
email: person.email,
phone: person.phone,
city: person.city,
createdAt: person.createdAt ? new Date(person.createdAt) : undefined,
company: person.company ? mapToCompany(person.company) : null,
});
export const mapToGqlPerson = (person: Person): GraphqlMutationPerson => ({
id: person.id,
firstname: person.firstname,
lastname: person.lastname,
email: person.email,
phone: person.phone,
city: person.city,
createdAt: person.createdAt ? person.createdAt.toUTCString() : undefined,
companyId: person.company?.id,
__typename: 'people',
});

View File

@ -0,0 +1,24 @@
import { reduceSortsToOrderBy } from '@/filters-and-sorts/helpers';
import { PeopleSelectedSortType } from '../select';
describe('reduceSortsToOrderBy', () => {
it('should return an array of objects with the id as key and the order as value', () => {
const sorts = [
{
key: 'firstname',
label: 'firstname',
order: 'asc',
_type: 'default_sort',
},
{
key: 'lastname',
label: 'lastname',
order: 'desc',
_type: 'default_sort',
},
] satisfies PeopleSelectedSortType[];
const result = reduceSortsToOrderBy(sorts);
expect(result).toEqual([{ firstname: 'asc' }, { lastname: 'desc' }]);
});
});

View File

@ -0,0 +1,50 @@
import {
GraphqlMutationPerson,
GraphqlQueryPerson,
} from '../../interfaces/person.interface';
import { updatePerson } from '../update';
jest.mock('~/apollo', () => {
const personInterface = jest.requireActual(
'@/people/interfaces/person.interface',
);
return {
apiClient: {
mutate: (arg: {
mutation: unknown;
variables: GraphqlMutationPerson;
}) => {
const gqlPerson = arg.variables as unknown as GraphqlQueryPerson;
return { data: personInterface.mapToPerson(gqlPerson) };
},
},
};
});
it('updates a person', async () => {
const result = await updatePerson({
firstname: 'John',
lastname: 'Doe',
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6c',
email: 'john@example.com',
company: {
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
name: 'ACME',
domainName: 'example.com',
__typename: 'companies',
},
phone: '+1 (555) 123-4567',
pipes: [
{
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6d',
name: 'Customer',
icon: '!',
},
],
createdAt: new Date(),
city: 'San Francisco',
__typename: 'people',
});
expect(result.data).toBeDefined();
result.data && expect(result.data.email).toBe('john@example.com');
});

View File

@ -0,0 +1,2 @@
export * from './select';
export * from './update';

View File

@ -0,0 +1,50 @@
import { gql, QueryResult, useQuery } from '@apollo/client';
import { SelectedSortType } from '@/filters-and-sorts/interfaces/sorts/interface';
import {
PersonOrderByWithRelationInput as People_Order_By,
PersonWhereInput as People_Bool_Exp,
SortOrder,
} from '~/generated/graphql';
import { GraphqlQueryPerson } from '../interfaces/person.interface';
export type PeopleSelectedSortType = SelectedSortType<People_Order_By>;
export const GET_PEOPLE = gql`
query GetPeople(
$orderBy: [PersonOrderByWithRelationInput!]
$where: PersonWhereInput
$limit: Int
) {
people: findManyPerson(orderBy: $orderBy, where: $where, take: $limit) {
id
phone
email
city
firstname
lastname
createdAt
company {
id
name
domainName
}
}
}
`;
export function usePeopleQuery(
orderBy: People_Order_By[],
where: People_Bool_Exp,
): QueryResult<{ people: GraphqlQueryPerson[] }> {
return useQuery<{ people: GraphqlQueryPerson[] }>(GET_PEOPLE, {
variables: { orderBy, where },
});
}
export const defaultOrderBy: People_Order_By[] = [
{
createdAt: SortOrder.Desc,
},
];

View File

@ -0,0 +1,122 @@
import { FetchResult, gql } from '@apollo/client';
import { apiClient } from '../../../apollo';
import { mapToGqlPerson, Person } from '../interfaces/person.interface';
export const UPDATE_PERSON = gql`
mutation UpdatePeople(
$id: String
$firstname: String
$lastname: String
$phone: String
$city: String
$companyId: String
$email: String
$createdAt: DateTime
) {
updateOnePerson(
where: { id: $id }
data: {
city: { set: $city }
company: { connect: { id: $companyId } }
email: { set: $email }
firstname: { set: $firstname }
id: { set: $id }
lastname: { set: $lastname }
phone: { set: $phone }
createdAt: { set: $createdAt }
}
) {
city
company {
domainName
name
id
}
email
firstname
id
lastname
phone
createdAt
}
}
`;
export const INSERT_PERSON = gql`
mutation InsertPerson(
$id: String!
$firstname: String!
$lastname: String!
$phone: String!
$city: String!
$email: String!
$createdAt: DateTime
) {
createOnePerson(
data: {
id: $id
firstname: $firstname
lastname: $lastname
phone: $phone
city: $city
email: $email
createdAt: $createdAt
}
) {
city
company {
domainName
name
id
}
email
firstname
id
lastname
phone
createdAt
}
}
`;
export const DELETE_PEOPLE = gql`
mutation DeletePeople($ids: [String!]) {
deleteManyPerson(where: { id: { in: $ids } }) {
count
}
}
`;
export async function updatePerson(
person: Person,
): Promise<FetchResult<Person>> {
const result = await apiClient.mutate({
mutation: UPDATE_PERSON,
variables: mapToGqlPerson(person),
});
return result;
}
export async function insertPerson(
person: Person,
): Promise<FetchResult<Person>> {
const result = await apiClient.mutate({
mutation: INSERT_PERSON,
variables: mapToGqlPerson(person),
refetchQueries: ['GetPeople'],
});
return result;
}
export async function deletePeople(
peopleIds: string[],
): Promise<FetchResult<Person>> {
const result = await apiClient.mutate({
mutation: DELETE_PEOPLE,
variables: { ids: peopleIds },
});
return result;
}

View File

@ -0,0 +1,33 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { Pipeline } from '../interfaces/pipeline.interface';
type OwnProps = {
opportunity: Pipeline;
};
const StyledContainer = styled.span`
background-color: ${(props) => props.theme.tertiaryBackground};
border-radius: ${(props) => props.theme.spacing(1)};
color: ${(props) => props.theme.text80};
display: inline-flex;
align-items: center;
padding: ${(props) => props.theme.spacing(1)};
gap: ${(props) => props.theme.spacing(1)};
:hover {
filter: brightness(95%);
}
`;
function PipelineChip({ opportunity }: OwnProps) {
return (
<StyledContainer data-testid="company-chip" key={opportunity.id}>
{opportunity.icon && <span>{opportunity.icon}</span>}
<span>{opportunity.name}</span>
</StyledContainer>
);
}
export default PipelineChip;

View File

@ -0,0 +1,12 @@
export interface Pipeline {
id: string;
name?: string;
icon?: string | null;
}
export interface GraphqlQueryPipeline {
id: string;
name?: string;
icon?: string | null;
__typename: string;
}

View File

@ -0,0 +1,26 @@
import { ReactNode } from 'react';
import { DocumentNode } from 'graphql';
import {
AnyEntity,
BoolExpType,
GqlType,
UnknownType,
} from '@/utils/interfaces/generic.interface';
export type SearchConfigType<
SearchType extends AnyEntity | UnknownType = UnknownType,
> = SearchType extends UnknownType
? {
query: DocumentNode;
template: (searchInput: string) => any;
resultMapper: (data: any) => any;
}
: {
query: DocumentNode;
template: (searchInput: string) => BoolExpType<SearchType>;
resultMapper: (data: GqlType<SearchType>) => {
value: SearchType;
render: (value: SearchType) => ReactNode;
};
};

View File

@ -0,0 +1,118 @@
import { useMemo, useState } from 'react';
import { gql, useQuery } from '@apollo/client';
import { debounce } from '@/utils/debounce';
import { AnyEntity, UnknownType } from '@/utils/interfaces/generic.interface';
import { SearchConfigType } from '../interfaces/interface';
export const SEARCH_PEOPLE_QUERY = gql`
query SearchPeopleQuery($where: PersonWhereInput, $limit: Int) {
searchResults: findManyPerson(where: $where, take: $limit) {
id
phone
email
city
firstname
lastname
createdAt
}
}
`;
export const SEARCH_USER_QUERY = gql`
query SearchUserQuery($where: UserWhereInput, $limit: Int) {
searchResults: findManyUser(where: $where, take: $limit) {
id
email
displayName
}
}
`;
// TODO: remove this query
export const EMPTY_QUERY = gql`
query EmptyQuery {
searchResults: findManyUser {
id
}
}
`;
export const SEARCH_COMPANY_QUERY = gql`
query SearchCompanyQuery($where: CompanyWhereInput, $limit: Int) {
searchResults: findManyCompany(where: $where, take: $limit) {
id
name
domainName
}
}
`;
export type SearchResultsType<T extends AnyEntity | UnknownType = UnknownType> =
{
results: {
render: (value: T) => string;
value: T;
}[];
loading: boolean;
};
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,
);
const [searchInput, setSearchInput] = useState<string>('');
const debouncedsetSearchInput = useMemo(
() => debounce(setSearchInput, 50),
[],
);
const where = useMemo(() => {
return (
searchConfig &&
searchConfig.template &&
searchConfig.template(searchInput)
);
}, [searchConfig, searchInput]);
const searchQueryResults = useQuery(searchConfig?.query || EMPTY_QUERY, {
variables: {
where,
limit: 5,
},
skip: !searchConfig,
});
const searchResults = useMemo<{
results: { render: (value: T) => string; value: any }[];
loading: boolean;
}>(() => {
if (searchConfig == null) {
return {
loading: false,
results: [],
};
}
if (searchQueryResults.loading) {
return {
loading: true,
results: [],
};
}
return {
loading: false,
// TODO: add proper typing
results: searchQueryResults?.data?.searchResults?.map(
searchConfig.resultMapper,
),
};
}, [searchConfig, searchQueryResults]);
return [searchResults, debouncedsetSearchInput, setSearchConfig, searchInput];
};

View File

@ -0,0 +1,36 @@
import styled from '@emotion/styled';
const StyledIconButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 50%;
background: ${(props) => props.theme.text80};
color: ${(props) => props.theme.text100};
transition: color 0.1s ease-in-out, background 0.1s ease-in-out;
background: ${(props) => props.theme.blue};
color: ${(props) => props.theme.text0};
cursor: pointer;
&:disabled {
background: ${(props) => props.theme.quadraryBackground};
color: ${(props) => props.theme.text80};
cursor: default;
}
`;
export function IconButton({
icon,
...props
}: { icon: React.ReactNode } & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return <StyledIconButton {...props}>{icon}</StyledIconButton>;
}

View File

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

View File

@ -0,0 +1,29 @@
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
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)};
margin-left: -2px;
position: absolute;
left: ${(props) =>
props.editModeHorizontalAlign === 'right' ? 'auto' : '0'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
border: 1px solid ${(props) => props.theme.primaryBorder};
z-index: 1;
border-radius: 4px;
${overlayBackground}
`;

View File

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

View File

@ -0,0 +1,58 @@
import { ReactElement } from 'react';
import { useRecoilState } from 'recoil';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellBaseContainer } from './CellBaseContainer';
import { CellNormalModeContainer } from './CellNormalModeContainer';
import { EditableCellEditMode } from './EditableCellEditMode';
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 [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
function handleOnClick() {
if (!isSomeInputInEditMode) {
onInsideClick?.();
setIsSomeInputInEditMode(true);
}
}
return (
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<EditableCellEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
/>
) : (
<CellNormalModeContainer>
<>{nonEditModeContent}</>
</CellNormalModeContainer>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,89 @@
import { ReactElement, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { debounce } from '@/utils/debounce';
import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellEditModeContainer } from './CellEditModeContainer';
type OwnProps = {
editModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCellEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
editModeContent,
isEditMode,
onOutsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const debouncedSetIsSomeInputInEditMode = useMemo(() => {
return debounce(setIsSomeInputInEditMode, 20);
}, [setIsSomeInputInEditMode]);
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isEditMode) {
debouncedSetIsSomeInputInEditMode(false);
onOutsideClick?.();
}
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
return (
<CellEditModeContainer
data-testid="editable-cell-edit-mode-container"
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</CellEditModeContainer>
);
}

View File

@ -0,0 +1,67 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellBaseContainer } from './CellBaseContainer';
import { EditableCellMenuEditMode } from './EditableCellMenuEditMode';
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 [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
function handleOnClick() {
if (!isSomeInputInEditMode) {
onInsideClick?.();
setIsSomeInputInEditMode(true);
}
}
return (
<CellBaseContainer onClick={handleOnClick}>
<EditableCellMenuNormalModeContainer>
{nonEditModeContent}
</EditableCellMenuNormalModeContainer>
{isEditMode && (
<EditableCellMenuEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
onInsideClick={onInsideClick}
/>
)}
</CellBaseContainer>
);
}

View File

@ -0,0 +1,88 @@
import { ReactElement, useMemo, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { debounce } from '@/utils/debounce';
import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { EditableCellMenuEditModeContainer } from './EditableCellMenuEditModeContainer';
type OwnProps = {
editModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCellMenuEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
editModeContent,
isEditMode,
onOutsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const debouncedSetIsSomeInputInEditMode = useMemo(() => {
return debounce(setIsSomeInputInEditMode, 20);
}, [setIsSomeInputInEditMode]);
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isEditMode) {
debouncedSetIsSomeInputInEditMode(false);
onOutsideClick?.();
}
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
return (
<EditableCellMenuEditModeContainer
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</EditableCellMenuEditModeContainer>
);
}

View File

@ -0,0 +1,27 @@
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
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' : '-1px'};
right: ${(props) =>
props.editModeHorizontalAlign === 'right' ? '0' : 'auto'};
top: ${(props) => (props.editModeVerticalPosition === 'over' ? '0' : '100%')};
border: 1px solid ${(props) => props.theme.primaryBorder};
z-index: 1;
border-radius: 4px;
${overlayBackground}
`;

View File

@ -0,0 +1,98 @@
import { ChangeEvent, ComponentType, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { CellCommentChip } from '@/comments/components/comments/CellCommentChip';
import { textInputStyle } from '../../layout/styles/themes';
import { EditableCell } from './EditableCell';
export type EditableChipProps = {
placeholder?: string;
value: string;
picture: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
ChipComponent: ComponentType<{ name: string; picture: string }>;
commentCount?: number;
onCommentClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
// TODO: refactor
const StyledInplaceInput = styled.input`
width: 100%;
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
${textInputStyle}
`;
const StyledInplaceShow = styled.div`
display: flex;
width: 100%;
&::placeholder {
font-weight: 'bold';
color: props.theme.text20;
}
`;
function EditableChip({
value,
placeholder,
changeHandler,
picture,
editModeHorizontalAlign,
ChipComponent,
commentCount,
onCommentClick,
}: EditableChipProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
const showComment = commentCount ? commentCount > 0 : false;
function handleCommentClick(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
onCommentClick?.(event);
}
return (
<EditableCell
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={
<>
<StyledInplaceShow>
<ChipComponent name={inputValue} picture={picture} />
</StyledInplaceShow>
{showComment && (
<CellCommentChip
count={commentCount ?? 0}
onClick={handleCommentClick}
/>
)}
</>
}
></EditableCell>
);
}
export default EditableChip;

View File

@ -0,0 +1,87 @@
import { forwardRef, useState } from 'react';
import styled from '@emotion/styled';
import { humanReadableDate } from '@/utils/utils';
import DatePicker from '../form/DatePicker';
import { EditableCell } from './EditableCell';
export type EditableDateProps = {
value: Date;
changeHandler: (date: Date) => void;
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledContainer = styled.div`
display: flex;
align-items: center;
`;
export type StyledCalendarContainerProps = {
editModeHorizontalAlign?: 'left' | 'right';
};
const StyledCalendarContainer = styled.div<StyledCalendarContainerProps>`
position: absolute;
border: 1px solid ${(props) => props.theme.primaryBorder};
border-radius: 8px;
box-shadow: 0px 3px 12px rgba(0, 0, 0, 0.09);
z-index: 1;
left: -10px;
top: 10px;
background: ${(props) => props.theme.secondaryBackground};
`;
export function EditableDate({
value,
changeHandler,
editModeHorizontalAlign,
}: EditableDateProps) {
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
type DivProps = React.HTMLProps<HTMLDivElement>;
const DateDisplay = forwardRef<HTMLDivElement, DivProps>(
({ value, onClick }, ref) => (
<div onClick={onClick} ref={ref}>
{value && humanReadableDate(new Date(value as string))}
</div>
),
);
interface DatePickerContainerProps {
children: React.ReactNode;
}
const DatePickerContainer = ({ children }: DatePickerContainerProps) => {
return <StyledCalendarContainer>{children}</StyledCalendarContainer>;
};
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledContainer>
<DatePicker
date={inputValue}
onChangeHandler={(date: Date) => {
changeHandler(date);
setInputValue(date);
}}
customInput={<DateDisplay />}
customCalendarContainer={DatePickerContainer}
/>
</StyledContainer>
}
nonEditModeContent={
<StyledContainer>
<div>{inputValue && humanReadableDate(inputValue)}</div>
</StyledContainer>
}
></EditableCell>
);
}

View File

@ -0,0 +1,75 @@
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
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%;
height: 18px;
${textInputStyle}
`;
export function EditableDoubleText({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
nonEditModeContent,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
onInsideClick={() => setIsEditMode(true)}
onOutsideClick={() => setIsEditMode(false)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={firstValuePlaceholder}
ref={firstValueInputRef}
value={firstValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
<StyledEditInplaceInput
placeholder={secondValuePlaceholder}
ref={firstValueInputRef}
value={secondValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
</StyledContainer>
}
nonEditModeContent={nonEditModeContent}
></EditableCell>
);
}

View File

@ -0,0 +1,73 @@
import { ChangeEvent, MouseEvent, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import Link from '../link/Link';
import { EditableCell } from './EditableCell';
type OwnProps = {
placeholder?: string;
value: string;
changeHandler: (updated: string) => void;
};
type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
border: none;
outline: none;
&::placeholder {
font-weight: bold;
color: ${(props) => props.theme.text20};
}
`;
export function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeContent={
<StyledEditInplaceInput
autoFocus
isEditMode={isEditMode}
placeholder={placeholder || ''}
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={
<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>
}
/>
);
}

View File

@ -0,0 +1,221 @@
import { ChangeEvent, ComponentType, useEffect, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { SearchConfigType } from '@/search/interfaces/interface';
import { useSearch } from '@/search/services/search';
import { AnyEntity } from '@/utils/interfaces/generic.interface';
import { isDefined } from '@/utils/type-guards/isDefined';
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
import { textInputStyle } from '../../layout/styles/themes';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { CellNormalModeContainer } from './CellNormalModeContainer';
import { EditableCellMenu } from './EditableCellMenu';
import { EditableRelationCreateButton } from './EditableRelationCreateButton';
import { HoverableMenuItem } from './HoverableMenuItem';
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(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%;
${textInputStyle}
`;
const StyledEditModeResults = styled.div`
border-top: 1px solid ${(props) => props.theme.primaryBorder};
padding-left: ${(props) => props.theme.spacing(1)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledEditModeResultItem = styled.div`
height: 32px;
display: flex;
align-items: center;
cursor: pointer;
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,
> = {
relation?: RelationType | null;
searchPlaceholder: string;
searchConfig: SearchConfigType<RelationType>;
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
export function EditableRelation<
RelationType extends AnyEntity,
ChipComponentPropsType,
>({
relation,
searchPlaceholder,
searchConfig,
onChange,
onChangeSearchInput,
editModeHorizontalAlign,
ChipComponent,
chipComponentPropsMapper,
onCreate,
}: EditableRelationProps<RelationType, ChipComponentPropsType>) {
const [isEditMode, setIsEditMode] = useState(false);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
// 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);
closeEditMode();
}
function closeEditMode() {
setIsEditMode(false);
setIsSomeInputInEditMode(false);
}
return (
<>
<EditableCellMenu
editModeHorizontalAlign={editModeHorizontalAlign}
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => {
if (!isEditMode) {
setIsEditMode(true);
}
}}
editModeContent={
<StyledEditModeContainer>
<StyledEditModeSelectedContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
autoFocus
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);
closeEditMode();
}}
>
<HoverableMenuItem>
<ChipComponent
{...chipComponentPropsMapper(result.value)}
/>
</HoverableMenuItem>
</StyledEditModeResultItem>
))}
</StyledEditModeResults>
</StyledEditModeContainer>
}
nonEditModeContent={
<CellNormalModeContainer>
{relation ? (
<ChipComponent {...chipComponentPropsMapper(relation)} />
) : (
<></>
)}
</CellNormalModeContainer>
}
/>
</>
);
}

View File

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

View File

@ -0,0 +1,61 @@
import { ChangeEvent, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
import { EditableCell } from './EditableCell';
type OwnProps = {
placeholder?: string;
content: string;
changeHandler: (updated: string) => void;
editModeHorizontalAlign?: 'left' | 'right';
};
type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledInplaceInput = styled.input<StyledEditModeProps>`
width: 100%;
${textInputStyle}
`;
const StyledNoEditText = styled.div`
width: 100%;
`;
export function EditableText({
content,
placeholder,
changeHandler,
editModeHorizontalAlign,
}: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(content);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
isEditMode={isEditMode}
placeholder={placeholder || ''}
autoFocus
ref={inputRef}
value={inputValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
changeHandler(event.target.value);
}}
/>
}
nonEditModeContent={<StyledNoEditText>{inputValue}</StyledNoEditText>}
></EditableCell>
);
}

View File

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

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import styled from '@emotion/styled';
type OwnProps = {
name: string;
id: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
const StyledContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
input[type='checkbox'] {
accent-color: ${(props) => props.theme.blue};
margin: 2px;
height: 14px;
width: 14px;
cursor: pointer;
user-select: none;
}
input[type='checkbox']::before {
content: '';
border: 1px solid ${(props) => props.theme.text40};
width: 12px;
height: 12px;
border-radius: 2px;
display: block;
}
input[type='checkbox']:hover::before {
border: 1px solid ${(props) => props.theme.text80};
}
input[type='checkbox']:checked::before {
border: 1px solid ${(props) => props.theme.blue};
}
`;
export function Checkbox({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
const ref = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
if (ref.current === null) return;
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !checked && indeterminate;
}
}, [ref, indeterminate, checked]);
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
if (onChange) {
onChange(event.target.checked);
}
}
return (
<StyledContainer>
<input
ref={ref}
type="checkbox"
data-testid="input-checkbox"
id={id}
name={name}
checked={checked}
onChange={handleInputChange}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,260 @@
import React, { forwardRef, ReactElement, useState } from 'react';
import ReactDatePicker, { CalendarContainerProps } from 'react-datepicker';
import styled from '@emotion/styled';
import { overlayBackground } from '../../layout/styles/themes';
import 'react-datepicker/dist/react-datepicker.css';
export type DatePickerProps = {
isOpen?: boolean;
date: Date;
onChangeHandler: (date: Date) => void;
customInput?: ReactElement;
customCalendarContainer?(props: CalendarContainerProps): React.ReactNode;
};
const StyledContainer = styled.div`
& .react-datepicker {
border-color: ${(props) => props.theme.primaryBorder};
background: transparent;
font-family: 'Inter';
font-size: ${(props) => props.theme.fontSizeMedium};
border: none;
display: block;
font-weight: 400;
}
& .react-datepicker-popper {
position: relative !important;
inset: auto !important;
transform: none !important;
padding: 0 !important;
}
& .react-datepicker__triangle::after {
display: none;
}
& .react-datepicker__triangle::before {
display: none;
}
// Header
& .react-datepicker__header {
background: transparent;
border: none;
}
& .react-datepicker__header__dropdown {
display: flex;
margin-left: ${(props) => props.theme.spacing(1)};
margin-bottom: ${(props) => props.theme.spacing(1)};
}
& .react-datepicker__month-dropdown-container,
& .react-datepicker__year-dropdown-container {
text-align: left;
border-radius: 4px;
margin-left: ${(props) => props.theme.spacing(1)};
margin-right: 0;
padding: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(4)};
background-color: ${(props) => props.theme.tertiaryBackground};
}
& .react-datepicker__month-read-view--down-arrow,
& .react-datepicker__year-read-view--down-arrow {
height: 5px;
width: 5px;
border-width: 1px 1px 0 0;
border-color: ${(props) => props.theme.text40};
top: 3px;
right: -6px;
}
& .react-datepicker__year-read-view,
& .react-datepicker__month-read-view {
padding-right: ${(props) => props.theme.spacing(2)};
}
& .react-datepicker__month-dropdown-container {
width: 80px;
}
& .react-datepicker__year-dropdown-container {
width: 50px;
}
& .react-datepicker__month-dropdown,
& .react-datepicker__year-dropdown {
border: ${(props) => props.theme.primaryBorder};
${overlayBackground}
overflow-y: scroll;
top: ${(props) => props.theme.spacing(2)};
}
& .react-datepicker__month-dropdown {
left: ${(props) => props.theme.spacing(2)};
width: 160px;
height: 260px;
}
& .react-datepicker__year-dropdown {
left: calc(${(props) => props.theme.spacing(9)} + 80px);
width: 100px;
height: 260px;
}
& .react-datepicker__navigation--years {
display: none;
}
& .react-datepicker__month-option--selected,
& .react-datepicker__year-option--selected {
display: none;
}
& .react-datepicker__year-option,
& .react-datepicker__month-option {
text-align: left;
padding: ${(props) => props.theme.spacing(2)}
calc(${(props) => props.theme.spacing(2)} - 2px);
width: calc(100% - ${(props) => props.theme.spacing(4)});
border-radius: 2px;
color: ${(props) => props.theme.text60};
cursor: pointer;
margin: 2px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
&:first-of-type {
display: none;
}
&:last-of-type {
display: none;
}
}
& .react-datepicker__current-month {
display: none;
}
& .react-datepicker__day-name {
color: ${(props) => props.theme.text60};
width: 34px;
height: 40px;
line-height: 40px;
}
& .react-datepicker__month-container {
float: none;
}
// Days
& .react-datepicker__month {
margin-top: 0;
}
& .react-datepicker__day {
width: 34px;
height: 34px;
line-height: 34px;
}
& .react-datepicker__day--selected {
background-color: ${(props) => props.theme.blue};
}
& .react-datepicker__navigation--previous,
& .react-datepicker__navigation--next {
height: 34px;
border-radius: 4px;
padding-top: 6px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
}
& .react-datepicker__navigation--previous {
right: 38px;
top: 8px;
left: auto;
& > span {
margin-left: -6px;
}
}
& .react-datepicker__navigation--next {
right: 6px;
top: 8px;
& > span {
margin-left: 6px;
}
}
& .react-datepicker__navigation-icon::before {
height: 7px;
width: 7px;
border-width: 1px 1px 0 0;
border-color: ${(props) => props.theme.text40};
}
& .react-datepicker__day--outside-month {
color: ${(props) => props.theme.text40};
}
& .react-datepicker__day--keyboard-selected {
background-color: inherit;
}
`;
function DatePicker({
date,
onChangeHandler,
customInput,
customCalendarContainer,
}: DatePickerProps) {
const [startDate, setStartDate] = useState(date);
type DivProps = React.HTMLProps<HTMLDivElement>;
const DefaultDateDisplay = 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 (
<StyledContainer>
<ReactDatePicker
open={true}
selected={startDate}
showMonthDropdown
showYearDropdown
onChange={(date: Date) => {
setStartDate(date);
onChangeHandler(date);
}}
customInput={customInput ? customInput : <DefaultDateDisplay />}
calendarContainer={
customCalendarContainer ? customCalendarContainer : undefined
}
/>
</StyledContainer>
);
}
export default DatePicker;

View File

@ -0,0 +1,62 @@
import { ChangeEvent, useRef } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '../../layout/styles/themes';
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%;
height: 18px;
${textInputStyle}
`;
export function DoubleTextInput({
leftValue,
rightValue,
leftValuePlaceholder,
rightValuePlaceholder,
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
return (
<StyledContainer>
<StyledEditInplaceInput
autoFocus
placeholder={leftValuePlaceholder}
ref={firstValueInputRef}
value={leftValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, rightValue);
}}
/>
<StyledEditInplaceInput
placeholder={rightValuePlaceholder}
ref={firstValueInputRef}
value={rightValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(leftValue, event.target.value);
}}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,30 @@
import * as React from 'react';
import { Link as ReactLink } from 'react-router-dom';
import styled from '@emotion/styled';
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;

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { Checkbox } from '../form/Checkbox';
type OwnProps = {
name: string;
id: string;
checked?: boolean;
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
};
const StyledContainer = styled.div`
width: 32px;
height: 32px;
margin-left: -${(props) => props.theme.table.horizontalCellMargin};
padding-left: ${(props) => props.theme.table.horizontalCellMargin};
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
`;
export function CheckboxCell({
name,
id,
checked,
onChange,
indeterminate,
}: OwnProps) {
const [internalChecked, setInternalChecked] = React.useState(checked);
function handleContainerClick() {
handleCheckboxChange(!internalChecked);
}
React.useEffect(() => {
setInternalChecked(checked);
}, [checked]);
function handleCheckboxChange(newCheckedValue: boolean) {
setInternalChecked(newCheckedValue);
if (onChange) {
onChange(newCheckedValue);
}
}
return (
<StyledContainer
onClick={handleContainerClick}
data-testid="input-checkbox-cell-container"
>
<Checkbox
id={id}
name={name}
checked={internalChecked}
onChange={handleCheckboxChange}
indeterminate={indeterminate}
/>
</StyledContainer>
);
}

View File

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
viewName: string;
viewIcon?: ReactNode;
};
const StyledTitle = styled.div`
display: flex;
flex-direction: row;
align-items: center;
height: ${(props) => props.theme.spacing(8)};
font-weight: 500;
padding-left: ${(props) => props.theme.spacing(2)};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(1)};
`;
export function ColumnHead({ viewName, viewIcon }: OwnProps) {
return (
<StyledTitle>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledTitle>
);
}

View File

@ -0,0 +1,180 @@
import * as React from 'react';
import styled from '@emotion/styled';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { useRecoilState } from 'recoil';
import {
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import { useResetTableRowSelection } from '../../tables/hooks/useResetTableRowSelection';
import { currentRowSelectionState } from '../../tables/states/rowSelectionState';
import { TableHeader } from './table-header/TableHeader';
type OwnProps<
TData extends { id: string; __typename: 'companies' | 'people' },
SortField,
> = {
data: Array<TData>;
columns: Array<ColumnDef<TData, any>>;
viewName: string;
viewIcon?: React.ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (filters: Array<SelectedFilterType<TData>>) => void;
onRowSelectionChange?: (rowSelection: string[]) => void;
};
const StyledTable = styled.table`
min-width: 1000px;
width: calc(100% - 2 * ${(props) => props.theme.table.horizontalCellMargin});
border-radius: 4px;
border-spacing: 0;
border-collapse: collapse;
margin-left: ${(props) => props.theme.table.horizontalCellMargin};
margin-right: ${(props) => props.theme.table.horizontalCellMargin};
table-layout: fixed;
th {
border-collapse: collapse;
color: ${(props) => props.theme.text40};
padding: 0;
border: 1px solid ${(props) => props.theme.tertiaryBackground};
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
}
}
td {
border-collapse: collapse;
color: ${(props) => props.theme.text80};
padding: 0;
border: 1px solid ${(props) => props.theme.tertiaryBackground};
text-align: left;
:last-child {
border-right-color: transparent;
}
:first-of-type {
border-left-color: transparent;
}
}
`;
const StyledTableWithHeader = styled.div`
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
`;
const StyledTableScrollableContainer = styled.div`
overflow: auto;
height: 100%;
flex: 1;
`;
export function EntityTable<
TData extends { id: string; __typename: 'companies' | 'people' },
SortField,
>({
data,
columns,
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<TData, SortField>) {
const [currentRowSelection, setCurrentRowSelection] = useRecoilState(
currentRowSelectionState,
);
const resetTableRowSelection = useResetTableRowSelection();
React.useEffect(() => {
resetTableRowSelection();
}, [resetTableRowSelection]);
const table = useReactTable<TData>({
data,
columns,
state: {
rowSelection: currentRowSelection,
},
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
onRowSelectionChange: setCurrentRowSelection,
getRowId: (row) => row.id,
});
return (
<StyledTableWithHeader>
<TableHeader
viewName={viewName}
viewIcon={viewIcon}
availableSorts={availableSorts}
availableFilters={availableFilters}
onSortsUpdate={onSortsUpdate}
onFiltersUpdate={onFiltersUpdate}
/>
<StyledTableScrollableContainer>
<StyledTable>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={{
width: `${header.getSize()}px`,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, index) => (
<tr key={row.id} data-testid={`row-id-${row.index}`}>
{row.getVisibleCells().map((cell) => {
return (
<td key={cell.id + row.original.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
);
})}
</tr>
))}
</tbody>
</StyledTable>
</StyledTableScrollableContainer>
</StyledTableWithHeader>
);
}

View File

@ -0,0 +1,18 @@
import { CheckboxCell } from './CheckboxCell';
export const SelectAllCheckbox = ({
indeterminate,
onChange,
}: {
indeterminate?: boolean;
onChange?: (newCheckedValue: boolean) => void;
} & React.HTMLProps<HTMLInputElement>) => {
return (
<CheckboxCell
name="select-all-checkbox"
id="select-all-checkbox"
indeterminate={indeterminate}
onChange={onChange}
/>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { selectedRowIdsState } from '@/ui/tables/states/selectedRowIdsState';
type OwnProps = {
children: React.ReactNode | React.ReactNode[];
};
const StyledContainer = styled.div`
display: flex;
position: absolute;
z-index: 1;
height: 48px;
bottom: 38px;
background: ${(props) => props.theme.secondaryBackground};
align-items: center;
padding-left: ${(props) => props.theme.spacing(2)};
padding-right: ${(props) => props.theme.spacing(2)};
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
border: 1px solid ${(props) => props.theme.primaryBorder};
`;
export function EntityTableActionBar({ children }: OwnProps) {
const selectedRowIds = useRecoilValue(selectedRowIdsState);
if (selectedRowIds.length === 0) {
return <></>;
}
return <StyledContainer>{children}</StyledContainer>;
}

View File

@ -0,0 +1,49 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
type OwnProps = {
icon: ReactNode;
label: string;
type?: 'standard' | 'warning';
onClick: () => void;
};
type StyledButtonProps = {
type: 'standard' | 'warning';
};
const StyledButton = styled.div<StyledButtonProps>`
display: flex;
cursor: pointer;
user-select: none;
color: ${(props) =>
props.type === 'warning' ? props.theme.red : props.theme.text60};
justify-content: center;
padding: ${(props) => props.theme.spacing(2)};
border-radius: 4px;
transition: background 0.1s ease;
&:hover {
background: ${(props) => props.theme.tertiaryBackground};
}
`;
const StyledButtonLabel = styled.div`
margin-left: ${(props) => props.theme.spacing(2)};
font-weight: 500;
`;
export function EntityTableActionBarButton({
label,
icon,
type = 'standard',
onClick,
}: OwnProps) {
return (
<StyledButton type={type} onClick={onClick}>
{icon}
<StyledButtonLabel>{label}</StyledButtonLabel>
</StyledButton>
);
}

View File

@ -0,0 +1,23 @@
import { FaRegComment } from 'react-icons/fa';
import { useOpenRightDrawer } from '@/ui/layout/right-drawer/hooks/useOpenRightDrawer';
import { EntityTableActionBarButton } from './EntityTableActionBarButton';
export function TableActionBarButtonToggleComments() {
// TODO: here it would be nice to access the table context
// But let's see when we have custom entities and properties
const openRightDrawer = useOpenRightDrawer();
async function handleButtonClick() {
openRightDrawer('comments');
}
return (
<EntityTableActionBarButton
label="Comment"
icon={<FaRegComment size={16} />}
onClick={handleButtonClick}
/>
);
}

View File

@ -0,0 +1,207 @@
import { ReactNode, useRef } from 'react';
import { FaAngleDown } from 'react-icons/fa';
import styled from '@emotion/styled';
import { useOutsideAlerter } from '../../../hooks/useOutsideAlerter';
import {
overlayBackground,
textInputStyle,
} from '../../../layout/styles/themes';
type OwnProps = {
label: string;
isActive: boolean;
children?: ReactNode;
isUnfolded?: boolean;
setIsUnfolded?: React.Dispatch<React.SetStateAction<boolean>>;
resetState?: () => void;
};
const StyledDropdownButtonContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
`;
type StyledDropdownButtonProps = {
isUnfolded: boolean;
isActive: boolean;
};
const StyledDropdownButton = styled.div<StyledDropdownButtonProps>`
display: flex;
margin-left: ${(props) => props.theme.spacing(3)};
cursor: pointer;
user-select: none;
background: ${(props) => props.theme.primaryBackground};
color: ${(props) => (props.isActive ? props.theme.blue : 'none')};
padding: ${(props) => props.theme.spacing(1)};
border-radius: 4px;
filter: ${(props) => (props.isUnfolded ? 'brightness(0.95)' : 'none')};
&:hover {
filter: brightness(0.95);
}
`;
const StyledDropdown = styled.ul`
--wraper-border: 1px;
--wraper-border-radius: 8px;
--outer-border-radius: calc(var(--wraper-border-radius) - 2px);
display: flex;
flex-direction: column;
position: absolute;
top: 14px;
right: 0;
border: var(--wraper-border) solid ${(props) => props.theme.primaryBorder};
border-radius: var(--wraper-border-radius);
padding: 0px;
min-width: 160px;
${overlayBackground}
li {
&:first-of-type {
border-top-left-radius: var(--outer-border-radius);
border-top-right-radius: var(--outer-border-radius);
}
&:last-of-type {
border-bottom-left-radius: var(--outer-border-radius);
border-bottom-right-radius: var(--outer-border-radius);
border-bottom: 0;
}
}
`;
const StyledDropdownItem = styled.li`
display: flex;
align-items: center;
width: calc(160px - ${(props) => props.theme.spacing(4)});
padding: ${(props) => props.theme.spacing(2)}
calc(${(props) => props.theme.spacing(2)} - 2px);
margin: 2px;
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text60};
border-radius: 2px;
&:hover {
background: rgba(0, 0, 0, 0.04);
}
`;
const StyledDropdownItemClipped = styled.span`
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;
const StyledDropdownTopOption = styled.li`
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(${(props) => props.theme.spacing(2)} + 2px)
calc(${(props) => props.theme.spacing(2)});
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text80};
font-weight: ${(props) => props.theme.fontWeightBold};
&:hover {
background: rgba(0, 0, 0, 0.04);
}
border-bottom: 1px solid ${(props) => props.theme.primaryBorder};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(1)};
min-width: ${(props) => props.theme.spacing(4)};
justify-content: center;
`;
const StyledSearchField = styled.li`
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
color: ${(props) => props.theme.text60};
font-weight: ${(props) => props.theme.fontWeightBold};
border-bottom: var(--wraper-border) solid
${(props) => props.theme.primaryBorder};
overflow: hidden;
input {
height: 36px;
width: 100%;
padding: 8px;
box-sizing: border-box;
font-family: ${(props) => props.theme.fontFamily};
border-radius: 8px;
${textInputStyle}
&:focus {
outline: 0 none;
}
}
`;
function DropdownButton({
label,
isActive,
children,
isUnfolded = false,
setIsUnfolded,
resetState,
}: OwnProps) {
const onButtonClick = () => {
setIsUnfolded && setIsUnfolded(!isUnfolded);
};
const onOutsideClick = () => {
setIsUnfolded && setIsUnfolded(false);
resetState && resetState();
};
const dropdownRef = useRef(null);
useOutsideAlerter(dropdownRef, onOutsideClick);
return (
<StyledDropdownButtonContainer>
<StyledDropdownButton
isUnfolded={isUnfolded}
onClick={onButtonClick}
isActive={isActive}
aria-selected={isActive}
>
{label}
</StyledDropdownButton>
{isUnfolded && (
<StyledDropdown ref={dropdownRef}>{children}</StyledDropdown>
)}
</StyledDropdownButtonContainer>
);
}
const StyleAngleDownContainer = styled.div`
margin-left: auto;
`;
function DropdownTopOptionAngleDown() {
return (
<StyleAngleDownContainer>
<FaAngleDown />
</StyleAngleDownContainer>
);
}
DropdownButton.StyledDropdownItem = StyledDropdownItem;
DropdownButton.StyledDropdownItemClipped = StyledDropdownItemClipped;
DropdownButton.StyledSearchField = StyledSearchField;
DropdownButton.StyledDropdownTopOption = StyledDropdownTopOption;
DropdownButton.StyledDropdownTopOptionAngleDown = DropdownTopOptionAngleDown;
DropdownButton.StyledIcon = StyledIcon;
export default DropdownButton;

View File

@ -0,0 +1,210 @@
import { ChangeEvent, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
FilterConfigType,
FilterOperandType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { SearchResultsType, useSearch } from '@/search/services/search';
import { humanReadableDate } from '@/utils/utils';
import DatePicker from '../../form/DatePicker';
import DropdownButton from './DropdownButton';
type OwnProps<TData extends FilterableFieldsType> = {
isFilterSelected: boolean;
availableFilters: FilterConfigType<TData>[];
onFilterSelect: (filter: SelectedFilterType<TData>) => void;
onFilterRemove: (filterId: SelectedFilterType<TData>['key']) => void;
};
export const FilterDropdownButton = <TData extends FilterableFieldsType>({
availableFilters,
onFilterSelect,
isFilterSelected,
onFilterRemove,
}: OwnProps<TData>) => {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOperandSelectionUnfolded, setIsOperandSelectionUnfolded] =
useState(false);
const [selectedFilter, setSelectedFilter] = useState<
FilterConfigType<TData> | undefined
>(undefined);
const [selectedFilterOperand, setSelectedFilterOperand] = useState<
FilterOperandType<TData> | undefined
>(undefined);
const [filterSearchResults, setSearchInput, setFilterSearch] = useSearch();
const resetState = useCallback(() => {
setIsOperandSelectionUnfolded(false);
setSelectedFilter(undefined);
setSelectedFilterOperand(undefined);
setFilterSearch(null);
}, [setFilterSearch]);
const renderOperandSelection = selectedFilter?.operands.map(
(filterOperand, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-operand-${index}`}
onClick={() => {
setSelectedFilterOperand(filterOperand);
setIsOperandSelectionUnfolded(false);
}}
>
{filterOperand.label}
</DropdownButton.StyledDropdownItem>
),
);
const renderFilterSelection = availableFilters.map((filter, index) => (
<DropdownButton.StyledDropdownItem
key={`select-filter-${index}`}
onClick={() => {
setSelectedFilter(filter);
setSelectedFilterOperand(filter.operands[0]);
filter.searchConfig && setFilterSearch(filter.searchConfig);
setSearchInput('');
}}
>
<DropdownButton.StyledIcon>{filter.icon}</DropdownButton.StyledIcon>
{filter.label}
</DropdownButton.StyledDropdownItem>
));
const renderSearchResults = (
filterSearchResults: SearchResultsType,
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) => {
if (filterSearchResults.loading) {
return (
<DropdownButton.StyledDropdownItem data-testid="loading-search-results">
Loading
</DropdownButton.StyledDropdownItem>
);
}
return filterSearchResults.results.map((result, index) => {
return (
<DropdownButton.StyledDropdownItem
key={`fields-value-${index}`}
onClick={() => {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: result.value,
displayValue: result.render(result.value),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
setIsUnfolded(false);
setSelectedFilter(undefined);
}}
>
<DropdownButton.StyledDropdownItemClipped>
{result.render(result.value)}
</DropdownButton.StyledDropdownItemClipped>
</DropdownButton.StyledDropdownItem>
);
});
};
function renderValueSelection(
selectedFilter: FilterConfigType<TData>,
selectedFilterOperand: FilterOperandType<TData>,
) {
return (
<>
<DropdownButton.StyledDropdownTopOption
key={'selected-filter-operand'}
onClick={() => setIsOperandSelectionUnfolded(true)}
>
{selectedFilterOperand.label}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>
<DropdownButton.StyledSearchField key={'search-filter'}>
{['text', 'relation'].includes(selectedFilter.type) && (
<input
type="text"
placeholder={selectedFilter.label}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
if (
selectedFilter.type === 'relation' &&
selectedFilter.searchConfig
) {
setFilterSearch(selectedFilter.searchConfig);
setSearchInput(event.target.value);
}
if (selectedFilter.type === 'text') {
if (event.target.value === '') {
onFilterRemove(selectedFilter.key);
} else {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: event.target.value,
displayValue: event.target.value,
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
}
}
}}
/>
)}
{selectedFilter.type === 'date' && (
<DatePicker
date={new Date()}
onChangeHandler={(date) => {
onFilterSelect({
key: selectedFilter.key,
label: selectedFilter.label,
value: date.toISOString(),
displayValue: humanReadableDate(date),
icon: selectedFilter.icon,
operand: selectedFilterOperand,
});
}}
customInput={<></>}
customCalendarContainer={styled.div`
top: -10px;
`}
/>
)}
</DropdownButton.StyledSearchField>
{selectedFilter.type === 'relation' &&
filterSearchResults &&
renderSearchResults(
filterSearchResults,
selectedFilter,
selectedFilterOperand,
)}
</>
);
}
return (
<DropdownButton
label="Filter"
isActive={isFilterSelected}
isUnfolded={isUnfolded}
setIsUnfolded={setIsUnfolded}
resetState={resetState}
>
{selectedFilter
? isOperandSelectionUnfolded
? renderOperandSelection
: renderValueSelection(selectedFilter, selectedFilterOperand)
: renderFilterSelection}
</DropdownButton>
);
};

View File

@ -0,0 +1,94 @@
import { FaArrowDown, FaArrowUp } from 'react-icons/fa';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import { SelectedSortType } from '@/filters-and-sorts/interfaces/sorts/interface';
import SortOrFilterChip from './SortOrFilterChip';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
sorts: Array<SelectedSortType<SortField>>;
onRemoveSort: (sortId: SelectedSortType<SortField>['key']) => void;
filters: Array<SelectedFilterType<TData>>;
onRemoveFilter: (filterId: SelectedFilterType<TData>['key']) => void;
onCancelClick: () => void;
};
const StyledBar = styled.div`
display: flex;
flex-direction: row;
border-top: 1px solid ${(props) => props.theme.primaryBorder};
align-items: center;
justify-content: space-between;
height: 40px;
`;
const StyledCancelButton = styled.button`
margin-left: auto;
border: none;
background-color: inherit;
padding: ${(props) => {
const horiz = props.theme.spacing(2);
const vert = props.theme.spacing(1);
return `${vert} ${horiz} ${vert} ${horiz}`;
}};
color: ${(props) => props.theme.text40};
font-weight: 500;
margin-right: ${(props) => props.theme.spacing(2)};
cursor: pointer;
user-select: none;
&:hover {
border-radius: ${(props) => props.theme.spacing(1)};
background-color: ${(props) => props.theme.tertiaryBackground};
}
`;
function SortAndFilterBar<SortField, TData extends FilterableFieldsType>({
sorts,
onRemoveSort,
filters,
onRemoveFilter,
onCancelClick,
}: OwnProps<SortField, TData>) {
return (
<StyledBar>
{sorts.map((sort) => {
return (
<SortOrFilterChip
key={sort.key}
labelValue={sort.label}
id={sort.key}
icon={sort.order === 'desc' ? <FaArrowDown /> : <FaArrowUp />}
onRemove={() => onRemoveSort(sort.key)}
/>
);
})}
{filters.map((filter) => {
return (
<SortOrFilterChip
key={filter.key}
labelKey={filter.label}
labelValue={`${filter.operand.label} ${filter.displayValue}`}
id={filter.key}
icon={filter.icon}
onRemove={() => onRemoveFilter(filter.key)}
/>
);
})}
{filters.length + sorts.length > 0 && (
<StyledCancelButton
data-testid={'cancel-button'}
onClick={onCancelClick}
>
Cancel
</StyledCancelButton>
)}
</StyledBar>
);
}
export default SortAndFilterBar;

View File

@ -0,0 +1,88 @@
import { useCallback, useState } from 'react';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import DropdownButton from './DropdownButton';
type OwnProps<SortField> = {
isSortSelected: boolean;
onSortSelect: (sort: SelectedSortType<SortField>) => void;
availableSorts: SortType<SortField>[];
};
const options: Array<SelectedSortType<any>['order']> = ['asc', 'desc'];
export function SortDropdownButton<SortField>({
isSortSelected,
availableSorts,
onSortSelect,
}: OwnProps<SortField>) {
const [isUnfolded, setIsUnfolded] = useState(false);
const [isOptionUnfolded, setIsOptionUnfolded] = useState(false);
const [selectedSortDirection, setSelectedSortDirection] =
useState<SelectedSortType<SortField>['order']>('asc');
const onSortItemSelect = useCallback(
(sort: SortType<SortField>) => {
onSortSelect({ ...sort, order: selectedSortDirection });
},
[onSortSelect, selectedSortDirection],
);
const resetState = useCallback(() => {
setIsOptionUnfolded(false);
setSelectedSortDirection('asc');
}, []);
return (
<DropdownButton
label="Sort"
isActive={isSortSelected}
isUnfolded={isUnfolded}
setIsUnfolded={setIsUnfolded}
resetState={resetState}
>
{isOptionUnfolded
? options.map((option, index) => (
<DropdownButton.StyledDropdownItem
key={index}
onClick={() => {
setSelectedSortDirection(option);
setIsOptionUnfolded(false);
}}
>
{option === 'asc' ? 'Ascending' : 'Descending'}
</DropdownButton.StyledDropdownItem>
))
: [
<DropdownButton.StyledDropdownTopOption
key={0}
onClick={() => setIsOptionUnfolded(true)}
>
{selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'}
<DropdownButton.StyledDropdownTopOptionAngleDown />
</DropdownButton.StyledDropdownTopOption>,
...availableSorts.map((sort, index) => (
<DropdownButton.StyledDropdownItem
key={index + 1}
onClick={() => {
setIsUnfolded(false);
onSortItemSelect(sort);
}}
>
<DropdownButton.StyledIcon>
{sort.icon}
</DropdownButton.StyledIcon>
{sort.label}
</DropdownButton.StyledDropdownItem>
)),
]}
</DropdownButton>
);
}

View File

@ -0,0 +1,64 @@
import { ReactNode } from 'react';
import { TbX } from 'react-icons/tb';
import styled from '@emotion/styled';
type OwnProps = {
id: string;
labelKey?: string;
labelValue: string;
icon: ReactNode;
onRemove: () => void;
};
const StyledChip = styled.div`
border-radius: 50px;
display: flex;
flex-direction: row;
background-color: ${(props) => props.theme.blueHighTransparency};
border: 1px solid ${(props) => props.theme.blueLowTransparency};
color: ${(props) => props.theme.blue};
padding: ${(props) => props.theme.spacing(1) + ' ' + props.theme.spacing(2)};
margin-left: ${(props) => props.theme.spacing(2)};
font-size: ${(props) => props.theme.fontSizeSmall};
align-items: center;
`;
const StyledIcon = styled.div`
margin-right: ${(props) => props.theme.spacing(1)};
display: flex;
align-items: center;
`;
const StyledDelete = styled.div`
margin-left: ${(props) => props.theme.spacing(2)};
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
font-size: ${(props) => props.theme.fontSizeSmall};
margin-top: 1px;
`;
const StyledLabelKey = styled.div`
font-weight: 500;
`;
function SortOrFilterChip({
id,
labelKey,
labelValue,
icon,
onRemove,
}: OwnProps) {
return (
<StyledChip>
<StyledIcon>{icon}</StyledIcon>
{labelKey && <StyledLabelKey>{labelKey}:&nbsp;</StyledLabelKey>}
{labelValue}
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + id}>
<TbX />
</StyledDelete>
</StyledChip>
);
}
export default SortOrFilterChip;

View File

@ -0,0 +1,168 @@
import { ReactNode, useCallback, useState } from 'react';
import styled from '@emotion/styled';
import {
FilterableFieldsType,
FilterConfigType,
SelectedFilterType,
} from '@/filters-and-sorts/interfaces/filters/interface';
import {
SelectedSortType,
SortType,
} from '@/filters-and-sorts/interfaces/sorts/interface';
import { FilterDropdownButton } from './FilterDropdownButton';
import SortAndFilterBar from './SortAndFilterBar';
import { SortDropdownButton } from './SortDropdownButton';
type OwnProps<SortField, TData extends FilterableFieldsType> = {
viewName: string;
viewIcon?: ReactNode;
availableSorts?: Array<SortType<SortField>>;
availableFilters?: FilterConfigType<TData>[];
onSortsUpdate?: (sorts: Array<SelectedSortType<SortField>>) => void;
onFiltersUpdate?: (sorts: Array<SelectedFilterType<TData>>) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledTableHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 40px;
color: ${(props) => props.theme.text60};
font-weight: 500;
padding-left: ${(props) => props.theme.spacing(3)};
padding-right: ${(props) => props.theme.spacing(1)};
`;
const StyledIcon = styled.div`
display: flex;
margin-right: ${(props) => props.theme.spacing(2)};
& > svg {
font-size: ${(props) => props.theme.fontSizeLarge};
}
`;
const StyledViewSection = styled.div`
display: flex;
`;
const StyledFilters = styled.div`
display: flex;
font-weight: 400;
margin-right: ${(props) => props.theme.spacing(2)};
`;
export function TableHeader<SortField, TData extends FilterableFieldsType>({
viewName,
viewIcon,
availableSorts,
availableFilters,
onSortsUpdate,
onFiltersUpdate,
}: OwnProps<SortField, TData>) {
const [sorts, innerSetSorts] = useState<Array<SelectedSortType<SortField>>>(
[],
);
const [filters, innerSetFilters] = useState<Array<SelectedFilterType<TData>>>(
[],
);
const sortSelect = useCallback(
(newSort: SelectedSortType<SortField>) => {
const newSorts = updateSortOrFilterByKey(sorts, newSort);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const sortUnselect = useCallback(
(sortKey: string) => {
const newSorts = sorts.filter((sort) => sort.key !== sortKey);
innerSetSorts(newSorts);
onSortsUpdate && onSortsUpdate(newSorts);
},
[onSortsUpdate, sorts],
);
const filterSelect = useCallback(
(filter: SelectedFilterType<TData>) => {
const newFilters = updateSortOrFilterByKey(filters, filter);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
const filterUnselect = useCallback(
(filterId: SelectedFilterType<TData>['key']) => {
const newFilters = filters.filter((filter) => filter.key !== filterId);
innerSetFilters(newFilters);
onFiltersUpdate && onFiltersUpdate(newFilters);
},
[onFiltersUpdate, filters],
);
return (
<StyledContainer>
<StyledTableHeader>
<StyledViewSection>
<StyledIcon>{viewIcon}</StyledIcon>
{viewName}
</StyledViewSection>
<StyledFilters>
<FilterDropdownButton
isFilterSelected={filters.length > 0}
availableFilters={availableFilters || []}
onFilterSelect={filterSelect}
onFilterRemove={filterUnselect}
/>
<SortDropdownButton<SortField>
isSortSelected={sorts.length > 0}
availableSorts={availableSorts || []}
onSortSelect={sortSelect}
/>
</StyledFilters>
</StyledTableHeader>
{sorts.length + filters.length > 0 && (
<SortAndFilterBar
sorts={sorts}
filters={filters}
onRemoveSort={sortUnselect}
onRemoveFilter={filterUnselect}
onCancelClick={() => {
innerSetFilters([]);
onFiltersUpdate && onFiltersUpdate([]);
innerSetSorts([]);
onSortsUpdate && onSortsUpdate([]);
}}
/>
)}
</StyledContainer>
);
}
function updateSortOrFilterByKey<SortOrFilter extends { key: string }>(
sorts: Readonly<SortOrFilter[]>,
newSort: SortOrFilter,
): SortOrFilter[] {
const newSorts = [...sorts];
const existingSortIndex = sorts.findIndex((sort) => sort.key === newSort.key);
if (existingSortIndex !== -1) {
newSorts[existingSortIndex] = newSort;
} else {
newSorts.push(newSort);
}
return newSorts;
}

View File

@ -0,0 +1,31 @@
import { useRef } from 'react';
import { act } from 'react-dom/test-utils';
import { fireEvent, render } from '@testing-library/react';
import { useOutsideAlerter } from '../useOutsideAlerter';
const onOutsideClick = jest.fn();
function TestComponent() {
const buttonRef = useRef(null);
useOutsideAlerter(buttonRef, onOutsideClick);
return (
<div>
<span>Outside</span>
<button ref={buttonRef}>Inside</button>
</div>
);
}
test('useOutsideAlerter hook works properly', async () => {
const { getByText } = render(<TestComponent />);
const inside = getByText('Inside');
const outside = getByText('Outside');
await act(() => Promise.resolve());
fireEvent.mouseDown(inside);
expect(onOutsideClick).toHaveBeenCalledTimes(0);
fireEvent.mouseDown(outside);
expect(onOutsideClick).toHaveBeenCalledTimes(1);
});

View File

@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { isDefined } from '../../../utils/type-guards/isDefined';
import { isDefined } from '@/utils/type-guards/isDefined';
export function useListenClickOutsideArrayOfRef<T extends HTMLElement>(
arrayOfRef: Array<React.RefObject<T>>,

View File

@ -0,0 +1,21 @@
import { useEffect } from 'react';
declare type CallbackType = () => void;
export function useOutsideAlerter(
ref: React.RefObject<HTMLInputElement>,
callback: CallbackType,
) {
useEffect(() => {
function handleClickOutside(event: Event) {
const target = event.target as HTMLButtonElement;
if (ref.current && !ref.current.contains(target)) {
callback();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, callback]);
}

View File

@ -0,0 +1,10 @@
import { TablerIconsProps } from '@tabler/icons-react';
import { ReactComponent as IconAddressBookRaw } from '../svgs/address-book.svg';
export function IconAddressBook(props: TablerIconsProps): JSX.Element {
const size = props.size ?? 24;
const stroke = props.stroke ?? 2;
return <IconAddressBookRaw height={size} width={size} strokeWidth={stroke} />;
}

View File

@ -0,0 +1 @@
export { IconMessageCircle as IconComment } from '@tabler/icons-react';

View File

@ -0,0 +1,3 @@
export { IconAddressBook } from './components/IconAddressBook';
export { IconComment } from './components/IconComment';
export { IconAward } from '@tabler/icons-react';

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-address-book" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2z" />
<path d="M10 16h6" />
<path d="M13 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 8h3" />
<path d="M4 12h3" />
<path d="M4 16h3" />
</svg>

After

Width:  |  Height:  |  Size: 539 B

View File

@ -0,0 +1,40 @@
import { ThemeProvider } from '@emotion/react';
import styled from '@emotion/styled';
import { User } from '@/users/interfaces/user.interface';
import { Navbar } from './navbar/Navbar';
import { lightTheme } from './styles/themes';
const StyledLayout = styled.div`
display: flex;
flex-direction: row;
width: 100vw;
height: 100vh;
background: ${(props) => props.theme.noisyBackground};
position: relative;
`;
const NAVBAR_WIDTH = '236px';
const MainContainer = styled.div`
display: flex;
flex-direction: row;
width: calc(100% - ${NAVBAR_WIDTH});
`;
type OwnProps = {
children: JSX.Element;
user?: User;
};
export function AppLayout({ children, user }: OwnProps) {
return (
<ThemeProvider theme={lightTheme}>
<StyledLayout>
<Navbar user={user} workspace={user?.workspaceMember?.workspace} />
<MainContainer>{children}</MainContainer>
</StyledLayout>
</ThemeProvider>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledPanel = styled.div`
display: flex;
background: ${(props) => props.theme.primaryBackground};
border-radius: 8px;
border: 1px solid ${(props) => props.theme.primaryBorder};
width: 100%;
`;
export function Panel({ children }: { children: React.ReactNode }) {
return <StyledPanel>{children}</StyledPanel>;
}

View File

@ -0,0 +1,68 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { Panel } from '../Panel';
import { RightDrawer } from '../right-drawer/components/RightDrawer';
import { isRightDrawerOpenState } from '../right-drawer/states/isRightDrawerOpenState';
import { TopBar } from '../top-bar/TopBar';
type OwnProps = {
children: JSX.Element;
title: string;
icon: ReactNode;
onAddButtonClick?: () => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const TOPBAR_HEIGHT = '48px';
const MainContainer = styled.div`
display: flex;
flex-direction: row;
width: calc(100% - ${(props) => props.theme.spacing(3)});
height: calc(100% - ${TOPBAR_HEIGHT} - ${(props) => props.theme.spacing(3)});
background: ${(props) => props.theme.noisyBackground};
padding-right: ${(props) => props.theme.spacing(3)};
padding-bottom: ${(props) => props.theme.spacing(3)};
`;
const RIGHT_DRAWER_WIDTH = '300px';
type LeftContainerProps = {
isRightDrawerOpen?: boolean;
};
const LeftContainer = styled.div<LeftContainerProps>`
display: flex;
width: calc(
100% - ${(props) => (props.isRightDrawerOpen ? RIGHT_DRAWER_WIDTH : '0px')}
);
position: relative;
`;
export function WithTopBarContainer({
children,
title,
icon,
onAddButtonClick,
}: OwnProps) {
const [isRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
return (
<StyledContainer>
<TopBar title={title} icon={icon} onAddButtonClick={onAddButtonClick} />
<MainContainer>
<LeftContainer isRightDrawerOpen={isRightDrawerOpen}>
<Panel>{children}</Panel>
</LeftContainer>
<RightDrawer />
</MainContainer>
</StyledContainer>
);
}

View File

@ -0,0 +1,60 @@
import { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
type OwnProps = {
label: string;
to: string;
active?: boolean;
icon: ReactNode;
};
type StyledItemProps = {
active?: boolean;
};
const StyledItem = styled.button<StyledItemProps>`
display: flex;
align-items: center;
border: none;
font-size: ${(props) => props.theme.fontSizeMedium};
cursor: pointer;
user-select: none;
background: ${(props) => (props.active ? 'rgba(0, 0, 0, 0.04)' : 'inherit')};
padding-top: ${(props) => props.theme.spacing(1)};
padding-bottom: ${(props) => props.theme.spacing(1)};
padding-left: ${(props) => props.theme.spacing(1)};
font-family: 'Inter';
color: ${(props) =>
props.active ? props.theme.text100 : props.theme.text60};
border-radius: 4px;
:hover {
background: rgba(0, 0, 0, 0.04);
color: ${(props) => props.theme.text100};
}
margin-bottom: calc(${(props) => props.theme.spacing(1)} / 2);
`;
const StyledItemLabel = styled.div`
display: flex;
margin-left: ${(props) => props.theme.spacing(2)};
`;
function NavItem({ label, icon, to, active }: OwnProps) {
const navigate = useNavigate();
return (
<StyledItem
onClick={() => {
navigate(to);
}}
active={active}
aria-selected={active}
>
{icon}
<StyledItemLabel>{label}</StyledItemLabel>
</StyledItem>
);
}
export default NavItem;

View File

@ -0,0 +1,22 @@
import styled from '@emotion/styled';
type OwnProps = {
label: string;
};
const StyledTitle = styled.div`
display: flex;
text-transform: uppercase;
color: ${(props) => props.theme.text30};
font-size: ${(props) => props.theme.fontSizeExtraSmall};
font-weight: 600;
padding-top: ${(props) => props.theme.spacing(1)};
padding-bottom: ${(props) => props.theme.spacing(2)};
padding-left: ${(props) => props.theme.spacing(1)};
`;
function NavTitle({ label }: OwnProps) {
return <StyledTitle>{label}</StyledTitle>;
}
export default NavTitle;

View File

@ -0,0 +1,64 @@
import { TbBuilding, TbUser } from 'react-icons/tb';
import { useMatch, useResolvedPath } from 'react-router-dom';
import styled from '@emotion/styled';
import { User } from '@/users/interfaces/user.interface';
import { Workspace } from '@/workspaces/interfaces/workspace.interface';
import NavItem from './NavItem';
import NavTitle from './NavTitle';
import WorkspaceContainer from './WorkspaceContainer';
const NavbarContainer = styled.div`
display: flex;
flex-direction: column;
width: 220px;
padding: ${(props) => props.theme.spacing(2)};
flex-shrink: 0;
`;
const NavItemsContainer = styled.div`
display: flex;
flex-direction: column;
margin-top: 40px;
`;
type OwnProps = {
user?: User;
workspace?: Workspace;
};
export function Navbar({ workspace }: OwnProps) {
return (
<>
<NavbarContainer>
{workspace && <WorkspaceContainer workspace={workspace} />}
<NavItemsContainer>
<NavTitle label="Workspace" />
<NavItem
label="People"
to="/people"
icon={<TbUser size={16} />}
active={
!!useMatch({
path: useResolvedPath('/people').pathname,
end: true,
})
}
/>
<NavItem
label="Companies"
to="/companies"
icon={<TbBuilding size={16} />}
active={
!!useMatch({
path: useResolvedPath('/companies').pathname,
end: true,
})
}
/>
</NavItemsContainer>
</NavbarContainer>
</>
);
}

View File

@ -0,0 +1,50 @@
import styled from '@emotion/styled';
import { Workspace } from '@/workspaces/interfaces/workspace.interface';
type OwnProps = {
workspace: Workspace;
};
const StyledContainer = styled.button`
display: inline-flex;
height: 34px;
align-items: center;
cursor: pointer;
user-select: none;
border: 0;
background: inherit;
padding: ${(props) => props.theme.spacing(2)};
margin-left: ${(props) => props.theme.spacing(1)};
align-self: flex-start;
`;
type StyledLogoProps = {
logo?: string | null;
};
const StyledLogo = styled.div<StyledLogoProps>`
background: url(${(props) => props.logo});
background-size: cover;
width: 16px;
height: 16px;
border-radius: 2px;
`;
const StyledName = styled.div`
margin-left: ${(props) => props.theme.spacing(1)};
font-family: 'Inter';
font-weight: 500;
font-size: ${(props) => props.theme.fontSizeLarge};
`;
function WorkspaceContainer({ workspace }: OwnProps) {
return (
<StyledContainer>
<StyledLogo logo={workspace.logo}></StyledLogo>
<StyledName>{workspace?.displayName}</StyledName>
</StyledContainer>
);
}
export default WorkspaceContainer;

View File

@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { isDefined } from '@/utils/type-guards/isDefined';
import { Panel } from '../../Panel';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerRouter } from './RightDrawerRouter';
const StyledRightDrawer = styled.div`
display: flex;
flex-direction: row;
width: 300px;
margin-left: ${(props) => props.theme.spacing(2)};
`;
export function RightDrawer() {
const [isRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
if (!isRightDrawerOpen || !isDefined(rightDrawerPage)) {
return <></>;
}
return (
<StyledRightDrawer>
<Panel>
<RightDrawerRouter />
</Panel>
</StyledRightDrawer>
);
}

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const RightDrawerBody = styled.div`
display: flex;
flex-direction: column;
padding: 8px;
`;

View File

@ -0,0 +1,8 @@
import styled from '@emotion/styled';
export const RightDrawerPage = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;

View File

@ -0,0 +1,16 @@
import { useRecoilState } from 'recoil';
import { RightDrawerComments } from '@/comments/components/comments/RightDrawerComments';
import { isDefined } from '@/utils/type-guards/isDefined';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
export function RightDrawerRouter() {
const [rightDrawerPage] = useRecoilState(rightDrawerPageState);
if (!isDefined(rightDrawerPage)) {
return <></>;
}
return rightDrawerPage === 'comments' ? <RightDrawerComments /> : <></>;
}

View File

@ -0,0 +1,34 @@
import styled from '@emotion/styled';
import { RightDrawerTopBarCloseButton } from './RightDrawerTopBarCloseButton';
const StyledRightDrawerTopBar = styled.div`
display: flex;
flex-direction: row;
height: 40px;
align-items: center;
justify-content: space-between;
padding-left: 8px;
padding-right: 8px;
font-size: 13px;
color: ${(props) => props.theme.text60};
border-bottom: 1px solid ${(props) => props.theme.lightBorder};
`;
const StyledTopBarTitle = styled.div`
align-items: center;
font-weight: 500;
`;
export function RightDrawerTopBar({
title,
}: {
title: string | null | undefined;
}) {
return (
<StyledRightDrawerTopBar>
<StyledTopBarTitle>{title}</StyledTopBarTitle>
<RightDrawerTopBarCloseButton />
</StyledRightDrawerTopBar>
);
}

View File

@ -0,0 +1,38 @@
import { FaTimes } from 'react-icons/fa';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
const StyledButton = styled.button`
height: 24px;
width: 24px;
border: 1px solid ${(props) => props.theme.lightBorder};
background: none;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
padding: 8px;
border-radius: 4px;
transition: ${(props) => props.theme.clickableElementBackgroundTransition};
&:hover {
background: ${(props) => props.theme.clickableElementBackgroundHover};
}
`;
export function RightDrawerTopBarCloseButton() {
const [, setIsRightDrawerOpen] = useRecoilState(isRightDrawerOpenState);
function handleButtonClick() {
setIsRightDrawerOpen(false);
}
return (
<StyledButton onClick={handleButtonClick}>
<FaTimes />
</StyledButton>
);
}

View File

@ -1,4 +1,5 @@
import { useRecoilState } from 'recoil';
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPage } from '../types/RightDrawerPage';

View File

@ -1,4 +1,5 @@
import { atom } from 'recoil';
import { RightDrawerPage } from '../types/RightDrawerPage';
export const rightDrawerPageState = atom<RightDrawerPage | null>({

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,130 @@
import { css } from '@emotion/react';
import DarkNoise from './dark-noise.jpg';
import LightNoise from './light-noise.jpg';
const commonTheme = {
fontSizeExtraSmall: '0.85rem',
fontSizeSmall: '0.92rem',
fontSizeMedium: '1rem',
fontSizeLarge: '1.08rem',
iconSizeSmall: '0.92rem',
iconSizeMedium: '1.08rem',
iconSizeLarge: '1.23rem',
fontWeightBold: 500,
fontFamily: 'Inter, sans-serif',
spacing: (multiplicator: number) => `${multiplicator * 4}px`,
table: {
horizontalCellMargin: '8px',
},
borderRadius: '4px',
};
const lightThemeSpecific = {
noisyBackground: `url(${LightNoise.toString()});`,
primaryBackground: '#fff',
secondaryBackground: '#fcfcfc',
tertiaryBackground: '#f5f5f5',
quadraryBackground: '#ebebeb',
pinkBackground: '#ffe5f4',
greenBackground: '#e6fff2',
purpleBackground: '#e0e0ff',
yellowBackground: '#fff2e7',
secondaryBackgroundTransparent: 'rgba(252, 252, 252, 0.8)',
primaryBorder: 'rgba(0, 0, 0, 0.08)',
lightBorder: '#f5f5f5',
clickableElementBackgroundHover: 'rgba(0, 0, 0, 0.04)',
clickableElementBackgroundTransition: 'background 0.1s ease',
text100: '#000',
text80: '#333333',
text60: '#666',
text40: '#999999',
text30: '#b3b3b3',
text20: '#cccccc',
text0: '#fff',
blue: '#1961ed',
pink: '#cc0078',
green: '#1e7e50',
purple: '#1111b7',
yellow: '#cc660a',
red: '#ff2e3f',
blueHighTransparency: 'rgba(25, 97, 237, 0.03)',
blueLowTransparency: 'rgba(25, 97, 237, 0.32)',
};
const darkThemeSpecific: typeof lightThemeSpecific = {
noisyBackground: `url(${DarkNoise.toString()});`,
primaryBackground: '#141414',
secondaryBackground: '#171717',
tertiaryBackground: '#333333',
quadraryBackground: '#444444',
pinkBackground: '#cc0078',
greenBackground: '#1e7e50',
purpleBackground: '#1111b7',
yellowBackground: '#cc660a',
secondaryBackgroundTransparent: 'rgba(23, 23, 23, 0.8)',
clickableElementBackgroundHover: 'rgba(0, 0, 0, 0.04)',
clickableElementBackgroundTransition: 'background 0.1s ease',
primaryBorder: 'rgba(255, 255, 255, 0.08)',
lightBorder: '#222222',
text100: '#ffffff',
text80: '#cccccc',
text60: '#999',
text40: '#666',
text30: '#4c4c4c',
text20: '#333333',
text0: '#000',
blue: '#6895ec',
pink: '#ffe5f4',
green: '#e6fff2',
purple: '#e0e0ff',
yellow: '#fff2e7',
red: '#ff2e3f',
blueHighTransparency: 'rgba(104, 149, 236, 0.03)',
blueLowTransparency: 'rgba(104, 149, 236, 0.32)',
};
export const overlayBackground = (props: any) =>
css`
background: ${props.theme.secondaryBackgroundTransparent};
backdrop-filter: blur(8px);
box-shadow: 0px 3px 12px rgba(0, 0, 0, 0.09);
`;
export const textInputStyle = (props: any) =>
css`
border: none;
outline: none;
background-color: transparent;
&::placeholder,
&::-webkit-input-placeholder {
font-family: ${props.theme.fontFamily};
color: ${props.theme.text30};
font-weight: ${props.theme.fontWeightBold};
}
`;
export const lightTheme = { ...commonTheme, ...lightThemeSpecific };
export const darkTheme = { ...commonTheme, ...darkThemeSpecific };
export type ThemeType = typeof lightTheme;

View File

@ -0,0 +1,63 @@
import { ReactNode } from 'react';
import { TbPlus } from 'react-icons/tb';
import styled from '@emotion/styled';
const TopBarContainer = styled.div`
display: flex;
flex-direction: row;
height: 38px;
align-items: center;
background: ${(props) => props.theme.noisyBackground};
padding: 8px;
font-size: 14px;
color: ${(props) => props.theme.text80};
`;
const TitleContainer = styled.div`
font-family: 'Inter';
margin-left: 4px;
font-size: 14px;
display: flex;
width: 100%;
`;
const AddButtonContainer = styled.div`
display: flex;
justify-self: flex-end;
flex-shrink: 0;
border: 1px solid ${(props) => props.theme.primaryBorder};
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 4px;
color: ${(props) => props.theme.text80};
cursor: pointer;
user-select: none;
margin-right: ${(props) => props.theme.spacing(1)};
`;
type OwnProps = {
title: string;
icon: ReactNode;
onAddButtonClick?: () => void;
};
export function TopBar({ title, icon, onAddButtonClick }: OwnProps) {
return (
<>
<TopBarContainer>
{icon}
<TitleContainer data-testid="top-bar-title">{title}</TitleContainer>
{onAddButtonClick && (
<AddButtonContainer
data-testid="add-button"
onClick={onAddButtonClick}
>
<TbPlus size={16} />
</AddButtonContainer>
)}
</TopBarContainer>
</>
);
}

View File

@ -1,6 +1,7 @@
import { useSetRecoilState } from 'recoil';
import { currentRowSelectionState } from '../states/rowSelectionState';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { currentRowSelectionState } from '../states/rowSelectionState';
export function useResetTableRowSelection() {
const setCurrentRowSelectionState = useSetRecoilState(

Some files were not shown because too many files have changed in this diff Show More