Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -0,0 +1,194 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import styled from '@emotion/styled';
|
||||
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { Person } from '@/people/types/Person';
|
||||
import { IconDotsVertical, IconLinkOff, IconTrash } from '@/ui/display/icon';
|
||||
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
|
||||
export type PeopleCardProps = {
|
||||
person: Pick<Person, 'id' | 'avatarUrl' | 'name' | 'jobTitle'>;
|
||||
hasBottomBorder?: boolean;
|
||||
};
|
||||
|
||||
const StyledCard = styled.div<{
|
||||
isHovered: boolean;
|
||||
hasBottomBorder?: boolean;
|
||||
}>`
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
background: ${({ theme, isHovered }) =>
|
||||
isHovered ? theme.background.tertiary : 'auto'};
|
||||
border-bottom: 1px solid
|
||||
${({ theme, hasBottomBorder }) =>
|
||||
hasBottomBorder ? theme.border.color.light : 'transparent'};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCardInfo = styled.div`
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
flex: 1 0 0;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: ${({ theme }) => theme.text.lineHeight.lg};
|
||||
`;
|
||||
|
||||
const StyledJobTitle = styled.div`
|
||||
border-radius: ${({ theme }) => theme.spacing(1)};
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding-bottom: ${({ theme }) => theme.spacing(0.5)};
|
||||
padding-left: ${({ theme }) => theme.spacing(0)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
padding-top: ${({ theme }) => theme.spacing(0.5)};
|
||||
|
||||
&:hover {
|
||||
background: ${({ theme }) => theme.background.tertiary};
|
||||
}
|
||||
`;
|
||||
|
||||
export const PeopleCard = ({
|
||||
person,
|
||||
hasBottomBorder = true,
|
||||
}: PeopleCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
|
||||
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
strategy: 'absolute',
|
||||
middleware: [offset(10), flip()],
|
||||
whileElementsMounted: autoUpdate,
|
||||
placement: 'right-start',
|
||||
});
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [refs.floating],
|
||||
callback: () => {
|
||||
setIsOptionsOpen(false);
|
||||
if (isOptionsOpen) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!isOptionsOpen) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleOptions = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsOptionsOpen(!isOptionsOpen);
|
||||
};
|
||||
|
||||
const {
|
||||
findManyRecordsQuery,
|
||||
updateOneRecordMutation,
|
||||
deleteOneRecordMutation,
|
||||
} = useObjectMetadataItem({
|
||||
objectNameSingular: 'person',
|
||||
});
|
||||
|
||||
const [updatePerson] = useMutation(updateOneRecordMutation);
|
||||
const [deletePerson] = useMutation(deleteOneRecordMutation);
|
||||
|
||||
const handleDetachPerson = async () => {
|
||||
await updatePerson({
|
||||
variables: {
|
||||
idToUpdate: person.id,
|
||||
input: {
|
||||
companyId: null,
|
||||
},
|
||||
},
|
||||
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeletePerson = () => {
|
||||
deletePerson({
|
||||
variables: {
|
||||
idToDelete: person.id,
|
||||
},
|
||||
refetchQueries: [getOperationName(findManyRecordsQuery) ?? ''],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledCard
|
||||
isHovered={isHovered}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={() => navigate(`/object/person/${person.id}`)}
|
||||
hasBottomBorder={hasBottomBorder}
|
||||
>
|
||||
<Avatar
|
||||
size="lg"
|
||||
type="rounded"
|
||||
placeholder={person.name.firstName + ' ' + person.name.lastName}
|
||||
avatarUrl={person.avatarUrl}
|
||||
/>
|
||||
<StyledCardInfo>
|
||||
<StyledTitle>
|
||||
{person.name.firstName + ' ' + person.name.lastName}
|
||||
</StyledTitle>
|
||||
{person.jobTitle && <StyledJobTitle>{person.jobTitle}</StyledJobTitle>}
|
||||
</StyledCardInfo>
|
||||
{isHovered && (
|
||||
<div ref={refs.setReference}>
|
||||
<FloatingIconButton
|
||||
onClick={handleToggleOptions}
|
||||
size="small"
|
||||
Icon={IconDotsVertical}
|
||||
/>
|
||||
{isOptionsOpen && (
|
||||
<DropdownMenu
|
||||
data-select-disable
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
>
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleDetachPerson}
|
||||
LeftIcon={IconLinkOff}
|
||||
text="Detach relation"
|
||||
/>
|
||||
<MenuItem
|
||||
onClick={handleDeletePerson}
|
||||
LeftIcon={IconTrash}
|
||||
text="Delete person"
|
||||
accent="danger"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import {
|
||||
EntityChip,
|
||||
EntityChipVariant,
|
||||
} from '@/ui/display/chip/components/EntityChip';
|
||||
|
||||
export type PersonChipProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
variant?: EntityChipVariant;
|
||||
};
|
||||
|
||||
export const PersonChip = ({
|
||||
id,
|
||||
name,
|
||||
avatarUrl,
|
||||
variant,
|
||||
}: PersonChipProps) => (
|
||||
<EntityChip
|
||||
entityId={id}
|
||||
linkToEntity={`/person/${id}`}
|
||||
name={name}
|
||||
avatarType="rounded"
|
||||
avatarUrl={avatarUrl}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
@ -0,0 +1,22 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
|
||||
|
||||
import { PersonChip } from '../PersonChip';
|
||||
|
||||
const meta: Meta<typeof PersonChip> = {
|
||||
title: 'Modules/People/PersonChip',
|
||||
component: PersonChip,
|
||||
decorators: [ComponentWithRouterDecorator],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PersonChip>;
|
||||
|
||||
export const SmallName: Story = {
|
||||
args: { id: 'tim_fake_id', name: 'Tim C.' },
|
||||
};
|
||||
|
||||
export const BigName: Story = {
|
||||
args: { id: 'steve_fake_id', name: 'Steve LoremIpsumLoremIpsumLoremIpsum' },
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
|
||||
import { Person } from '@/people/types/Person';
|
||||
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
||||
import { SpreadsheetOptions } from '@/spreadsheet-import/types';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
|
||||
import { fieldsForPerson } from '../utils/fieldsForPerson';
|
||||
|
||||
export type FieldPersonMapping = (typeof fieldsForPerson)[number]['key'];
|
||||
|
||||
export const useSpreadsheetPersonImport = () => {
|
||||
const { openSpreadsheetImport } = useSpreadsheetImport<FieldPersonMapping>();
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
|
||||
const { createManyRecords: createManyPeople } = useCreateManyRecords<Person>({
|
||||
objectNameSingular: 'person',
|
||||
});
|
||||
|
||||
const openPersonSpreadsheetImport = (
|
||||
options?: Omit<
|
||||
SpreadsheetOptions<FieldPersonMapping>,
|
||||
'fields' | 'isOpen' | 'onClose'
|
||||
>,
|
||||
) => {
|
||||
openSpreadsheetImport({
|
||||
...options,
|
||||
onSubmit: async (data) => {
|
||||
// TODO: Add better type checking in spreadsheet import later
|
||||
const createInputs = data.validData.map((person) => ({
|
||||
id: v4(),
|
||||
name: {
|
||||
firstName: person.firstName as string | undefined,
|
||||
lastName: person.lastName as string | undefined,
|
||||
},
|
||||
email: person.email as string | undefined,
|
||||
...(person.linkedinUrl
|
||||
? {
|
||||
linkedinLink: {
|
||||
label: 'linkedinUrl',
|
||||
url: person.linkedinUrl as string | undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(person.xUrl
|
||||
? {
|
||||
xLink: {
|
||||
label: 'xUrl',
|
||||
url: person.xUrl as string | undefined,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
jobTitle: person.jobTitle as string | undefined,
|
||||
phone: person.phone as string | undefined,
|
||||
city: person.city as string | undefined,
|
||||
}));
|
||||
// TODO: abstract this part for any object
|
||||
try {
|
||||
await createManyPeople(createInputs);
|
||||
} catch (error: any) {
|
||||
enqueueSnackBar(error?.message || 'Something went wrong', {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
fields: fieldsForPerson,
|
||||
});
|
||||
};
|
||||
|
||||
return { openPersonSpreadsheetImport };
|
||||
};
|
||||
24
packages/twenty-front/src/modules/people/types/Person.ts
Normal file
24
packages/twenty-front/src/modules/people/types/Person.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export type Person = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
avatarUrl: string;
|
||||
jobTitle: string;
|
||||
linkedinLink: {
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
xLink: {
|
||||
url: string;
|
||||
label: string;
|
||||
};
|
||||
city: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
companyId: string;
|
||||
};
|
||||
@ -0,0 +1,100 @@
|
||||
import { isValidPhoneNumber } from 'libphonenumber-js';
|
||||
|
||||
import { Fields } from '@/spreadsheet-import/types';
|
||||
import {
|
||||
IconBrandLinkedin,
|
||||
IconBrandX,
|
||||
IconBriefcase,
|
||||
IconMail,
|
||||
IconMap,
|
||||
IconUser,
|
||||
} from '@/ui/display/icon';
|
||||
|
||||
export const fieldsForPerson = [
|
||||
{
|
||||
icon: IconUser,
|
||||
label: 'Firstname',
|
||||
key: 'firstName',
|
||||
alternateMatches: ['first name', 'first', 'firstname'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'Tim',
|
||||
},
|
||||
{
|
||||
icon: IconUser,
|
||||
label: 'Lastname',
|
||||
key: 'lastName',
|
||||
alternateMatches: ['last name', 'last', 'lastname'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'Cook',
|
||||
},
|
||||
{
|
||||
icon: IconMail,
|
||||
label: 'Email',
|
||||
key: 'email',
|
||||
alternateMatches: ['email', 'mail'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'tim@apple.dev',
|
||||
},
|
||||
{
|
||||
icon: IconBrandLinkedin,
|
||||
label: 'Linkedin URL',
|
||||
key: 'linkedinUrl',
|
||||
alternateMatches: ['linkedIn', 'linkedin', 'linkedin url'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'https://www.linkedin.com/in/timcook',
|
||||
},
|
||||
{
|
||||
icon: IconBrandX,
|
||||
label: 'X URL',
|
||||
key: 'xUrl',
|
||||
alternateMatches: ['x', 'x url'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'https://x.com/tim_cook',
|
||||
},
|
||||
{
|
||||
icon: IconBriefcase,
|
||||
label: 'Job title',
|
||||
key: 'jobTitle',
|
||||
alternateMatches: ['job', 'job title'],
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'CEO',
|
||||
},
|
||||
{
|
||||
icon: IconBriefcase,
|
||||
label: 'Phone',
|
||||
key: 'phone',
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: '+1234567890',
|
||||
validations: [
|
||||
{
|
||||
rule: 'function',
|
||||
isValid: (value: string) => isValidPhoneNumber(value),
|
||||
errorMessage: 'phone is not valid',
|
||||
level: 'error',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: IconMap,
|
||||
label: 'City',
|
||||
key: 'city',
|
||||
fieldType: {
|
||||
type: 'input',
|
||||
},
|
||||
example: 'Seattle',
|
||||
},
|
||||
] as Fields<string>;
|
||||
Reference in New Issue
Block a user