Reorganize frontend and install Craco to alias modules (#190)
This commit is contained in:
20
front/src/modules/auth/components/RequireAuth.tsx
Normal file
20
front/src/modules/auth/components/RequireAuth.tsx
Normal 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;
|
||||
}
|
||||
52
front/src/modules/auth/services/AuthService.ts
Normal file
52
front/src/modules/auth/services/AuthService.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>,
|
||||
),
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { CommentableEntity } from '../types/CommentableEntity';
|
||||
|
||||
export const commentableEntityArrayState = atom<CommentableEntity[]>({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { CommentableType } from '../../../generated/graphql';
|
||||
import { CommentableType } from '~/generated/graphql';
|
||||
|
||||
export type CommentableEntity = {
|
||||
type: keyof typeof CommentableType;
|
||||
|
||||
44
front/src/modules/companies/components/CompanyChip.tsx
Normal file
44
front/src/modules/companies/components/CompanyChip.tsx
Normal 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;
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
78
front/src/modules/companies/interfaces/company.interface.ts
Normal file
78
front/src/modules/companies/interfaces/company.interface.ts
Normal 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',
|
||||
});
|
||||
@ -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' }]);
|
||||
});
|
||||
});
|
||||
2
front/src/modules/companies/services/index.ts
Normal file
2
front/src/modules/companies/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './select';
|
||||
export * from './update';
|
||||
48
front/src/modules/companies/services/select.ts
Normal file
48
front/src/modules/companies/services/select.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
111
front/src/modules/companies/services/update.ts
Normal file
111
front/src/modules/companies/services/update.ts
Normal 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;
|
||||
}
|
||||
47
front/src/modules/filters-and-sorts/helpers.ts
Normal file
47
front/src/modules/filters-and-sorts/helpers.ts
Normal 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[];
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
@ -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';
|
||||
};
|
||||
@ -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} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
113
front/src/modules/people/components/PeopleCompanyCell.tsx
Normal file
113
front/src/modules/people/components/PeopleCompanyCell.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
46
front/src/modules/people/components/PersonChip.tsx
Normal file
46
front/src/modules/people/components/PersonChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
front/src/modules/people/components/person-placeholder.png
Normal file
BIN
front/src/modules/people/components/person-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@ -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);
|
||||
});
|
||||
});
|
||||
77
front/src/modules/people/interfaces/person.interface.ts
Normal file
77
front/src/modules/people/interfaces/person.interface.ts
Normal 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',
|
||||
});
|
||||
24
front/src/modules/people/services/__tests__/select.test.ts
Normal file
24
front/src/modules/people/services/__tests__/select.test.ts
Normal 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' }]);
|
||||
});
|
||||
});
|
||||
50
front/src/modules/people/services/__tests__/update.test.ts
Normal file
50
front/src/modules/people/services/__tests__/update.test.ts
Normal 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');
|
||||
});
|
||||
2
front/src/modules/people/services/index.ts
Normal file
2
front/src/modules/people/services/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './select';
|
||||
export * from './update';
|
||||
50
front/src/modules/people/services/select.ts
Normal file
50
front/src/modules/people/services/select.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
122
front/src/modules/people/services/update.ts
Normal file
122
front/src/modules/people/services/update.ts
Normal 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;
|
||||
}
|
||||
33
front/src/modules/pipelines/components/PipelineChip.tsx
Normal file
33
front/src/modules/pipelines/components/PipelineChip.tsx
Normal 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;
|
||||
12
front/src/modules/pipelines/interfaces/pipeline.interface.ts
Normal file
12
front/src/modules/pipelines/interfaces/pipeline.interface.ts
Normal 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;
|
||||
}
|
||||
26
front/src/modules/search/interfaces/interface.ts
Normal file
26
front/src/modules/search/interfaces/interface.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
118
front/src/modules/search/services/search.ts
Normal file
118
front/src/modules/search/services/search.ts
Normal 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];
|
||||
};
|
||||
36
front/src/modules/ui/components/buttons/IconButton.tsx
Normal file
36
front/src/modules/ui/components/buttons/IconButton.tsx
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
`;
|
||||
@ -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}
|
||||
`;
|
||||
@ -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)};
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
`;
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
`;
|
||||
79
front/src/modules/ui/components/form/Checkbox.tsx
Normal file
79
front/src/modules/ui/components/form/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
260
front/src/modules/ui/components/form/DatePicker.tsx
Normal file
260
front/src/modules/ui/components/form/DatePicker.tsx
Normal 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;
|
||||
62
front/src/modules/ui/components/inputs/DoubleTextInput.tsx
Normal file
62
front/src/modules/ui/components/inputs/DoubleTextInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
front/src/modules/ui/components/link/Link.tsx
Normal file
30
front/src/modules/ui/components/link/Link.tsx
Normal 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;
|
||||
66
front/src/modules/ui/components/table/CheckboxCell.tsx
Normal file
66
front/src/modules/ui/components/table/CheckboxCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
front/src/modules/ui/components/table/ColumnHead.tsx
Normal file
30
front/src/modules/ui/components/table/ColumnHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
front/src/modules/ui/components/table/EntityTable.tsx
Normal file
180
front/src/modules/ui/components/table/EntityTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
front/src/modules/ui/components/table/SelectAllCheckbox.tsx
Normal file
18
front/src/modules/ui/components/table/SelectAllCheckbox.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}: </StyledLabelKey>}
|
||||
{labelValue}
|
||||
<StyledDelete onClick={onRemove} data-testid={'remove-icon-' + id}>
|
||||
<TbX />
|
||||
</StyledDelete>
|
||||
</StyledChip>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortOrFilterChip;
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
@ -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>>,
|
||||
21
front/src/modules/ui/hooks/useOutsideAlerter.ts
Normal file
21
front/src/modules/ui/hooks/useOutsideAlerter.ts
Normal 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]);
|
||||
}
|
||||
10
front/src/modules/ui/icons/components/IconAddressBook.tsx
Normal file
10
front/src/modules/ui/icons/components/IconAddressBook.tsx
Normal 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} />;
|
||||
}
|
||||
1
front/src/modules/ui/icons/components/IconComment.tsx
Normal file
1
front/src/modules/ui/icons/components/IconComment.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { IconMessageCircle as IconComment } from '@tabler/icons-react';
|
||||
3
front/src/modules/ui/icons/index.ts
Normal file
3
front/src/modules/ui/icons/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { IconAddressBook } from './components/IconAddressBook';
|
||||
export { IconComment } from './components/IconComment';
|
||||
export { IconAward } from '@tabler/icons-react';
|
||||
11
front/src/modules/ui/icons/svgs/address-book.svg
Normal file
11
front/src/modules/ui/icons/svgs/address-book.svg
Normal 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 |
40
front/src/modules/ui/layout/AppLayout.tsx
Normal file
40
front/src/modules/ui/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
front/src/modules/ui/layout/Panel.tsx
Normal file
14
front/src/modules/ui/layout/Panel.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
60
front/src/modules/ui/layout/navbar/NavItem.tsx
Normal file
60
front/src/modules/ui/layout/navbar/NavItem.tsx
Normal 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;
|
||||
22
front/src/modules/ui/layout/navbar/NavTitle.tsx
Normal file
22
front/src/modules/ui/layout/navbar/NavTitle.tsx
Normal 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;
|
||||
64
front/src/modules/ui/layout/navbar/Navbar.tsx
Normal file
64
front/src/modules/ui/layout/navbar/Navbar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
front/src/modules/ui/layout/navbar/WorkspaceContainer.tsx
Normal file
50
front/src/modules/ui/layout/navbar/WorkspaceContainer.tsx
Normal 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const RightDrawerBody = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 8px;
|
||||
`;
|
||||
@ -0,0 +1,8 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const RightDrawerPage = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
@ -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 /> : <></>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { isRightDrawerOpenState } from '../states/isRightDrawerOpenState';
|
||||
import { rightDrawerPageState } from '../states/rightDrawerPageState';
|
||||
import { RightDrawerPage } from '../types/RightDrawerPage';
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { RightDrawerPage } from '../types/RightDrawerPage';
|
||||
|
||||
export const rightDrawerPageState = atom<RightDrawerPage | null>({
|
||||
|
||||
BIN
front/src/modules/ui/layout/styles/dark-noise.jpg
Normal file
BIN
front/src/modules/ui/layout/styles/dark-noise.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
front/src/modules/ui/layout/styles/light-noise.jpg
Normal file
BIN
front/src/modules/ui/layout/styles/light-noise.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
130
front/src/modules/ui/layout/styles/themes.ts
Normal file
130
front/src/modules/ui/layout/styles/themes.ts
Normal 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;
|
||||
63
front/src/modules/ui/layout/top-bar/TopBar.tsx
Normal file
63
front/src/modules/ui/layout/top-bar/TopBar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user