Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -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>
);
};

View File

@ -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}
/>
);

View File

@ -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' },
};

View File

@ -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 };
};

View 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;
};

View File

@ -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>;