Merge branch 'main' into 3628-timebox-separate-user-creation-from-workspace-creation

This commit is contained in:
martmull
2024-02-05 10:51:16 +01:00
278 changed files with 6123 additions and 2646 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ Good first issues are a great way to start contributing to the project and get f
2. Use the "Labels" filter and select "[Good First Issue](https://github.com/twentyhq/twenty/labels/good%20first%20issue)" to see a list of beginner-friendly tasks.
3. Choose an issue that interests you, fork the project, and start working on it. Once you solve and test the issue, open a PR for review.
Note: We are aware that having multiple contributors address the same issue can cause frustration. To prevent this, we adhere to a specific guideline: if a core team member has assigned an issue to a contributor, either as the issue assignee or through explicit assignment in the issue comments within the past three days, that contributor's pull request takes precedence. Otherwise, the first PR submitted will be given priority.
Note: We are aware that having multiple contributors address the same issue can cause frustration. To prevent this, we adhere to a specific guideline: if a core team member has assigned an issue to a contributor, either as the issue assignee or through explicit assignment in the issue comments within the past three days, that contributor's pull request takes precedence. Otherwise, the first PR submitted will be given priority. This delay is reduced to one day for PR tagged with "size: minutes" and extended to a week for PR tagged "size: days".
Therefore, ensure you are assigned to an issue before beginning work on it.
+3 -1
View File
@@ -17,4 +17,6 @@
!.yarn/sdks
!.yarn/versions
coverage
.vercel
.vercel
**/**/logs/**
+2 -2
View File
@@ -9,7 +9,7 @@
"@blocknote/react": "^0.10.1",
"@chakra-ui/accordion": "^2.3.0",
"@chakra-ui/system": "^2.6.0",
"@codesandbox/sandpack-react": "^2.9.0",
"@codesandbox/sandpack-react": "^2.11.3",
"@docusaurus/core": "^3.0.0",
"@docusaurus/preset-classic": "^3.0.0",
"@emotion/react": "^11.11.1",
@@ -126,7 +126,7 @@
"react-error-boundary": "^4.0.11",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.3",
"react-hotkeys-hook": "^4.4.4",
"react-icons": "^4.12.0",
"react-intersection-observer": "^9.5.2",
"react-loading-skeleton": "^3.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-docs",
"version": "0.1.0",
"version": "0.3.0",
"private": true,
"scripts": {
"nx": "NX_DEFAULT_PROJECT=twenty-docs node ../../node_modules/nx/bin/nx.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
"version": "0.1.0",
"version": "0.3.0",
"description": "",
"author": "",
"private": true,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
"version": "0.2.3",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
@@ -60,4 +60,4 @@
"msw": {
"workerDirectory": "public"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -77,7 +77,7 @@ export const CommentHeader = ({ comment, actionBar }: CommentHeaderProps) => {
<Avatar
avatarUrl={avatarUrl}
size="md"
colorId={author?.id}
entityId={author?.id}
placeholder={authorName}
/>
<StyledName>{authorName}</StyledName>
@@ -1,30 +0,0 @@
import {
IconCheckbox,
IconNotes,
IconTimelineEvent,
} from '@/ui/display/icon/index';
import { Button } from '@/ui/input/button/components/Button';
import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup';
type ActivityCreateButtonProps = {
onNoteClick?: () => void;
onTaskClick?: () => void;
onActivityClick?: () => void;
};
export const ActivityCreateButton = ({
onNoteClick,
onTaskClick,
onActivityClick,
}: ActivityCreateButtonProps) => (
<ButtonGroup variant={'secondary'}>
<Button Icon={IconNotes} title="Note" onClick={onNoteClick} />
<Button Icon={IconCheckbox} title="Task" onClick={onTaskClick} />
<Button
Icon={IconTimelineEvent}
title="Activity"
soon={true}
onClick={onActivityClick}
/>
</ButtonGroup>
);
@@ -0,0 +1,17 @@
import { Loader } from '@/ui/feedback/loader/components/Loader';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
StyledEmptyContainer,
StyledEmptyTextContainer,
StyledEmptyTitle,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
export const EmailLoader = ({ loadingText }: { loadingText?: string }) => (
<StyledEmptyContainer>
<AnimatedPlaceholder type="loadingMessages" />
<StyledEmptyTextContainer>
<StyledEmptyTitle>{loadingText || 'Loading emails'}</StyledEmptyTitle>
<Loader />
</StyledEmptyTextContainer>
</StyledEmptyContainer>
);
@@ -3,7 +3,7 @@ import styled from '@emotion/styled';
import { grayScale } from '@/ui/theme/constants/colors';
type FetchMoreLoaderProps = {
type EmailThreadFetchMoreLoaderProps = {
loading: boolean;
onLastRowVisible: (...args: any[]) => any;
};
@@ -18,10 +18,10 @@ const StyledText = styled.div`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
export const FetchMoreLoader = ({
export const EmailThreadFetchMoreLoader = ({
loading,
onLastRowVisible,
}: FetchMoreLoaderProps) => {
}: EmailThreadFetchMoreLoaderProps) => {
const { ref: tbodyRef } = useInView({
onChange: onLastRowVisible,
});
@@ -27,6 +27,7 @@ const StyledHeading = styled.h2`
margin: 0;
margin-bottom: ${({ theme }) => theme.spacing(3)};
max-width: 100%;
color: ${({ theme }) => theme.font.color.primary};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -11,7 +11,7 @@ const StyledThreadMessage = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(4, 6)};
padding: ${({ theme }) => theme.spacing(4, 0)};
`;
const StyledThreadMessageHeader = styled.div`
@@ -20,6 +20,11 @@ const StyledThreadMessageHeader = styled.div`
flex-direction: column;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(0, 6)};
`;
const StyledThreadMessageBody = styled.div`
padding: ${({ theme }) => theme.spacing(0, 6)};
`;
type EmailThreadMessageProps = {
@@ -45,16 +50,21 @@ export const EmailThreadMessage = ({
}
return (
<StyledThreadMessage>
<StyledThreadMessageHeader onClick={() => setIsOpen(!isOpen)}>
<StyledThreadMessage
onClick={() => !isOpen && setIsOpen(true)}
style={{ cursor: isOpen ? 'auto' : 'pointer' }}
>
<StyledThreadMessageHeader onClick={() => isOpen && setIsOpen(false)}>
<EmailThreadMessageSender sender={from} sentAt={sentAt} />
{isOpen && <EmailThreadMessageReceivers receivers={receivers} />}
</StyledThreadMessageHeader>
{isOpen ? (
<EmailThreadMessageBody body={body} />
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
<StyledThreadMessageBody>
{isOpen ? (
<EmailThreadMessageBody body={body} isDisplayed />
) : (
<EmailThreadMessageBodyPreview body={body} />
)}
</StyledThreadMessageBody>
</StyledThreadMessage>
);
};
@@ -1,20 +1,30 @@
import React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledThreadMessageBody = styled.div`
import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut';
const StyledThreadMessageBody = styled(motion.div)`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: column;
margin-top: ${({ theme }) => theme.spacing(4)};
white-space: pre-line;
overflow-wrap: break-word;
`;
type EmailThreadMessageBodyProps = {
body: string;
isDisplayed: boolean;
};
export const EmailThreadMessageBody = ({
body,
isDisplayed,
}: EmailThreadMessageBodyProps) => {
return <StyledThreadMessageBody>{body}</StyledThreadMessageBody>;
return (
<AnimatedEaseInOut isOpen={isDisplayed} duration="fast">
<StyledThreadMessageBody>{body}</StyledThreadMessageBody>
</AnimatedEaseInOut>
);
};
@@ -1,9 +1,8 @@
import React from 'react';
import styled from '@emotion/styled';
const StyledThreadMessageBodyPreview = styled.span`
const StyledThreadMessageBodyPreview = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -23,7 +23,9 @@ const StyledAvatar = styled(Avatar)`
`;
const StyledSenderName = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
`;
@@ -35,6 +37,10 @@ const StyledThreadMessageSentAt = styled.div`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledRecordChip = styled(RecordChip)`
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
type EmailThreadMessageSenderProps = {
sender: EmailThreadMessageParticipant;
sentAt: string;
@@ -57,7 +63,7 @@ export const EmailThreadMessageSender = ({
<StyledEmailThreadMessageSender>
<StyledEmailThreadMessageSenderUser>
{person ? (
<RecordChip
<StyledRecordChip
objectNameSingular={CoreObjectNameSingular.Person}
record={person}
/>
@@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { CardContent } from '@/ui/layout/card/components/CardContent';
import { grayScale } from '@/ui/theme/constants/colors';
import { Avatar } from '@/users/components/Avatar';
import { TimelineThread } from '~/generated/graphql';
import { formatToHumanReadableDate } from '~/utils';
@@ -15,29 +16,13 @@ const StyledCardContent = styled(CardContent)`
`;
const StyledHeading = styled.div<{ unread: boolean }>`
align-items: center;
color: ${({ theme, unread }) =>
unread ? theme.font.color.primary : theme.font.color.secondary};
display: flex;
font-weight: ${({ theme, unread }) =>
unread ? theme.font.weight.medium : theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
width: 160px;
:before {
background-color: ${({ theme, unread }) =>
unread ? theme.color.blue : 'transparent'};
border-radius: ${({ theme }) => theme.border.radius.rounded};
content: '';
display: block;
height: 6px;
width: 6px;
}
width: 20%;
`;
const StyledParticipantsContainer = styled.div`
align-items: center;
align-items: flex-start;
display: flex;
`;
@@ -46,6 +31,7 @@ const StyledAvatar = styled(Avatar)`
`;
const StyledSenderNames = styled.span`
display: flex;
margin: ${({ theme }) => theme.spacing(0, 1)};
overflow: hidden;
text-overflow: ellipsis;
@@ -56,26 +42,26 @@ const StyledThreadCount = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledSubject = styled.span<{ unread: boolean }>`
color: ${({ theme, unread }) =>
unread ? theme.font.color.primary : theme.font.color.secondary};
white-space: nowrap;
`;
const StyledBody = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledSubjectAndBody = styled.div`
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
`;
const StyledSubject = styled.span`
color: ${({ theme }) => theme.font.color.primary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
`;
const StyledBody = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledReceivedAt = styled.div`
@@ -104,12 +90,13 @@ export const EmailThreadPreview = ({
? `, ${thread.lastTwoParticipants?.[1]?.displayName}`
: '');
const [finalDisplayedName, finalAvatarUrl] =
const [finalDisplayedName, finalAvatarUrl, isCountIcon] =
thread.participantCount > 3
? [`${thread.participantCount}`, '']
? [`${thread.participantCount}`, '', true]
: [
thread?.lastTwoParticipants?.[1]?.displayName,
thread?.lastTwoParticipants?.[1]?.avatarUrl,
false,
];
return (
@@ -133,6 +120,8 @@ export const EmailThreadPreview = ({
avatarUrl={finalAvatarUrl}
placeholder={finalDisplayedName}
type="rounded"
color={isCountIcon ? grayScale.gray50 : undefined}
backgroundColor={isCountIcon ? grayScale.gray10 : undefined}
/>
)}
</StyledParticipantsContainer>
@@ -142,7 +131,7 @@ export const EmailThreadPreview = ({
</StyledHeading>
<StyledSubjectAndBody>
<StyledSubject unread={!thread.read}>{thread.subject}</StyledSubject>
<StyledSubject>{thread.subject}</StyledSubject>
<StyledBody>{thread.lastMessageBody}</StyledBody>
</StyledSubjectAndBody>
<StyledReceivedAt>
@@ -3,15 +3,14 @@ import { useQuery } from '@apollo/client';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader';
import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview';
import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/messaging.constants';
import { useEmailThreadStates } from '@/activities/emails/hooks/internal/useEmailThreadStates';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId';
import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId';
import {
emailThreadsPageState,
EmailThreadsPageType,
} from '@/activities/emails/state/emailThreadsPageState';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
@@ -19,9 +18,16 @@ import {
H1TitleFontColor,
} from '@/ui/display/typography/components/H1Title';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
import {
StyledEmptyContainer,
StyledEmptySubTitle,
StyledEmptyTextContainer,
StyledEmptyTitle,
} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled';
import { Card } from '@/ui/layout/card/components/Card';
import { Section } from '@/ui/layout/section/components/Section';
import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import {
GetTimelineThreadsFromPersonIdQueryVariables,
TimelineThread,
@@ -54,8 +60,13 @@ export const EmailThreads = ({
const { openEmailThread } = useEmailThread();
const { enqueueSnackBar } = useSnackBar();
const [emailThreadsPage, setEmailThreadsPage] =
useRecoilState<EmailThreadsPageType>(emailThreadsPageState);
const { getEmailThreadsPageState } = useEmailThreadStates({
emailThreadScopeId: getScopeIdFromComponentId(entity.id),
});
const [emailThreadsPage, setEmailThreadsPage] = useRecoilState(
getEmailThreadsPageState(),
);
const [isFetchingMoreEmails, setIsFetchingMoreEmails] = useState(false);
@@ -72,12 +83,21 @@ export const EmailThreads = ({
pageSize: TIMELINE_THREADS_DEFAULT_PAGE_SIZE,
} as GetTimelineThreadsFromPersonIdQueryVariables;
const { data, loading, fetchMore, error } = useQuery(threadQuery, {
const {
data,
loading: firstQueryLoading,
fetchMore,
error,
} = useQuery(threadQuery, {
variables: threadQueryVariables,
});
const fetchMoreRecords = async () => {
if (emailThreadsPage.hasNextPage && !isFetchingMoreEmails) {
if (
emailThreadsPage.hasNextPage &&
!isFetchingMoreEmails &&
!firstQueryLoading
) {
setIsFetchingMoreEmails(true);
await fetchMore({
@@ -91,7 +111,14 @@ export const EmailThreads = ({
...emailThreadsPage,
hasNextPage: false,
}));
return prev;
return {
[queryName]: {
...prev?.[queryName],
timelineThreads: [
...(prev?.[queryName]?.timelineThreads ?? []),
],
},
};
}
return {
@@ -122,32 +149,49 @@ export const EmailThreads = ({
const { totalNumberOfThreads, timelineThreads }: TimelineThreadsWithTotal =
data?.[queryName] ?? [];
if (firstQueryLoading) {
return <EmailLoader />;
}
if (!firstQueryLoading && !timelineThreads?.length) {
return (
<StyledEmptyContainer>
<AnimatedPlaceholder type="emptyInbox" />
<StyledEmptyTextContainer>
<StyledEmptyTitle>Empty Inbox</StyledEmptyTitle>
<StyledEmptySubTitle>
No email exchange has occurred with this record yet.
</StyledEmptySubTitle>
</StyledEmptyTextContainer>
</StyledEmptyContainer>
);
}
return (
<StyledContainer>
<Section>
<StyledH1Title
title={
<>
Inbox{' '}
<StyledEmailCount>{totalNumberOfThreads ?? 0}</StyledEmailCount>
Inbox <StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
/>
{!loading && (
{!firstQueryLoading && (
<Card>
{timelineThreads?.map((thread: TimelineThread, index: number) => (
<EmailThreadPreview
key={index}
divider={index < timelineThreads.length - 1}
thread={thread}
onClick={() => openEmailThread(thread)}
onClick={() => openEmailThread(thread.id)}
/>
))}
</Card>
)}
<FetchMoreLoader
loading={isFetchingMoreEmails}
<EmailThreadFetchMoreLoader
loading={isFetchingMoreEmails || firstQueryLoading}
onLastRowVisible={fetchMoreRecords}
/>
</Section>
@@ -0,0 +1,22 @@
import { emailThreadsPageStateScopeMap } from '@/activities/emails/state/emailThreadsPageStateScopeMap';
import { TabListScopeInternalContext } from '@/ui/layout/tab/scopes/scope-internal-context/TabListScopeInternalContext';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getState } from '@/ui/utilities/recoil-scope/utils/getState';
type useEmailThreadStatesProps = {
emailThreadScopeId?: string;
};
export const useEmailThreadStates = ({
emailThreadScopeId,
}: useEmailThreadStatesProps) => {
const scopeId = useAvailableScopeIdOrThrow(
TabListScopeInternalContext,
emailThreadScopeId,
);
return {
scopeId,
getEmailThreadsPageState: getState(emailThreadsPageStateScopeMap, scopeId),
};
};
@@ -1,18 +1,19 @@
import { useRecoilState } from 'recoil';
import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer';
import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState';
import { TimelineThread } from '~/generated/graphql';
import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState';
export const useEmailThread = () => {
const [, setViewableEmailThread] = useRecoilState(viewableEmailThreadState);
const [, setViewableEmailThreadId] = useRecoilState(
viewableEmailThreadIdState,
);
const openEmailThredRightDrawer = useOpenEmailThreadRightDrawer();
const openEmailThread = (thread: TimelineThread) => {
const openEmailThread = (threadId: string) => {
openEmailThredRightDrawer();
setViewableEmailThread(thread);
setViewableEmailThreadId(threadId);
};
return {
@@ -1,14 +1,11 @@
import React, { useCallback } from 'react';
import React from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { viewableEmailThreadState } from '@/activities/emails/state/viewableEmailThreadState';
import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { FetchMoreLoader } from '@/ui/utilities/loading-state/components/FetchMoreLoader';
import { useRightDrawerEmailThread } from '@/activities/emails/right-drawer/hooks/useRightDrawerEmailThread';
const StyledContainer = styled.div`
box-sizing: border-box;
@@ -21,53 +18,37 @@ const StyledContainer = styled.div`
`;
export const RightDrawerEmailThread = () => {
const viewableEmailThread = useRecoilValue(viewableEmailThreadState);
const { thread, messages, fetchMoreMessages, loading } =
useRightDrawerEmailThread();
const {
records: messages,
loading,
fetchMoreRecords,
} = useFindManyRecords<EmailThreadMessageType>({
depth: 3,
limit: 10,
filter: {
messageThreadId: {
eq: viewableEmailThread?.id,
},
},
objectNameSingular: CoreObjectNameSingular.Message,
orderBy: {
receivedAt: 'DescNullsLast',
},
skip: !viewableEmailThread,
useRecordsWithoutConnection: true,
});
const fetchMoreMessages = useCallback(() => {
if (!loading) {
fetchMoreRecords();
}
}, [fetchMoreRecords, loading]);
if (!viewableEmailThread) {
if (!thread) {
return null;
}
return (
<StyledContainer>
<EmailThreadHeader
subject={viewableEmailThread.subject}
lastMessageSentAt={viewableEmailThread.lastMessageReceivedAt}
subject={thread.subject}
lastMessageSentAt={thread.lastMessageReceivedAt}
/>
{messages.map((message) => (
<EmailThreadMessage
key={message.id}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<FetchMoreLoader loading={loading} onLastRowVisible={fetchMoreMessages} />
{loading ? (
<EmailLoader loadingText="Loading thread" />
) : (
<>
{messages.map((message) => (
<EmailThreadMessage
key={message.id}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<EmailThreadFetchMoreLoader
loading={loading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
);
};
@@ -0,0 +1,58 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import gql from 'graphql-tag';
import { useRecoilValue } from 'recoil';
import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState';
import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
export const useRightDrawerEmailThread = () => {
const viewableEmailThreadId = useRecoilValue(viewableEmailThreadIdState);
const apolloClient = useApolloClient();
const thread = apolloClient.readFragment({
id: `TimelineThread:${viewableEmailThreadId}`,
fragment: gql`
fragment timelineThread on TimelineThread {
id
subject
lastMessageReceivedAt
}
`,
});
const {
records: messages,
loading,
fetchMoreRecords,
} = useFindManyRecords<EmailThreadMessageType>({
depth: 3,
limit: 10,
filter: {
messageThreadId: {
eq: viewableEmailThreadId || '',
},
},
objectNameSingular: CoreObjectNameSingular.Message,
orderBy: {
receivedAt: 'AscNullsLast',
},
skip: !viewableEmailThreadId,
useRecordsWithoutConnection: true,
});
const fetchMoreMessages = useCallback(() => {
if (!loading) {
fetchMoreRecords();
}
}, [fetchMoreRecords, loading]);
return {
thread,
messages,
loading,
fetchMoreMessages,
};
};
@@ -1,11 +0,0 @@
import { atom } from 'recoil';
export type EmailThreadsPageType = {
pageNumber: number;
hasNextPage: boolean;
};
export const emailThreadsPageState = atom<EmailThreadsPageType>({
key: 'EmailThreadsPageState',
default: { pageNumber: 1, hasNextPage: true },
});
@@ -0,0 +1,12 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export type EmailThreadsPageType = {
pageNumber: number;
hasNextPage: boolean;
};
export const emailThreadsPageStateScopeMap =
createStateScopeMap<EmailThreadsPageType>({
key: 'emailThreadsPageStateScopeMap',
defaultValue: { pageNumber: 1, hasNextPage: true },
});
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const viewableEmailThreadIdState = atom<string | null>({
key: 'viewableEmailThreadIdState',
default: null,
});
@@ -1,8 +0,0 @@
import { atom } from 'recoil';
import { TimelineThread } from '~/generated/graphql';
export const viewableEmailThreadState = atom<TimelineThread | null>({
key: 'viewableEmailThreadState',
default: null,
});
@@ -63,7 +63,7 @@ export const TaskGroups = ({
<StyledEmptyTextContainer>
<StyledEmptyTitle>No Task</StyledEmptyTitle>
<StyledEmptySubTitle>
There are no associated tasks with this record
There are no tasks for this user filter
</StyledEmptySubTitle>
</StyledEmptyTextContainer>
<Button
@@ -1,7 +1,6 @@
import styled from '@emotion/styled';
import { ActivityCreateButton } from '@/activities/components/ActivityCreateButton';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { TimelineCreateButtonGroup } from '@/activities/timeline/components/TimelineCreateButtonGroup';
import { useTimelineActivities } from '@/activities/timeline/hooks/useTimelineActivities';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder';
@@ -36,8 +35,6 @@ export const Timeline = ({
targetableObject,
});
const openCreateActivity = useOpenCreateActivityDrawer();
const showEmptyState = initialized && activities.length === 0;
const showLoadingState = !initialized;
@@ -57,20 +54,7 @@ export const Timeline = ({
There are no activities associated with this record.{' '}
</StyledEmptySubTitle>
</StyledEmptyTextContainer>
<ActivityCreateButton
onNoteClick={() =>
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
})
}
onTaskClick={() =>
openCreateActivity({
type: 'Task',
targetableObjects: [targetableObject],
})
}
/>
<TimelineCreateButtonGroup targetableObject={targetableObject} />
</StyledEmptyContainer>
);
}
@@ -0,0 +1,53 @@
import { useSetRecoilState } from 'recoil';
import { Button, ButtonGroup } from 'tsup.ui.index';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import {
IconCheckbox,
IconNotes,
IconPaperclip,
} from '@/ui/display/icon/index';
import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
export const TimelineCreateButtonGroup = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const { getActiveTabIdState } = useTabList(TAB_LIST_COMPONENT_ID);
const setActiveTabId = useSetRecoilState(getActiveTabIdState());
const openCreateActivity = useOpenCreateActivityDrawer();
return (
<ButtonGroup variant={'secondary'}>
<Button
Icon={IconNotes}
title="Note"
onClick={() =>
openCreateActivity({
type: 'Note',
targetableObjects: [targetableObject],
})
}
/>
<Button
Icon={IconCheckbox}
title="Task"
onClick={() =>
openCreateActivity({
type: 'Task',
targetableObjects: [targetableObject],
})
}
/>
<Button
Icon={IconPaperclip}
title="File"
onClick={() => setActiveTabId('files')}
/>
</ButtonGroup>
);
};
@@ -1,76 +0,0 @@
import { useRecoilCallback } from 'recoil';
import { TriggerUpdateRelationFieldOptimisticEffectParams } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export const useGetRelationFieldsToOptimisticallyUpdate = () =>
useRecoilCallback(
({ snapshot }) =>
<UpdatedObjectRecord extends ObjectRecord = ObjectRecord>({
cachedRecord,
objectMetadataItem,
updateRecordInput,
}: {
cachedRecord: UpdatedObjectRecord & { __typename: string };
objectMetadataItem: ObjectMetadataItem;
updateRecordInput: Partial<Omit<UpdatedObjectRecord, 'id'>>;
}) =>
Object.entries(updateRecordInput).reduce<
Pick<
TriggerUpdateRelationFieldOptimisticEffectParams,
| 'relationObjectMetadataNameSingular'
| 'relationFieldName'
| 'previousRelationRecord'
| 'nextRelationRecord'
>[]
>((result, [fieldName, nextRelationRecord]) => {
const fieldDefinition = objectMetadataItem.fields.find(
(fieldMetadataItem) => fieldMetadataItem.name === fieldName,
);
if (fieldDefinition?.type !== FieldMetadataType.Relation)
return result;
const relationObjectMetadataNameSingular = (
fieldDefinition.toRelationMetadata?.fromObjectMetadata ||
fieldDefinition.fromRelationMetadata?.toObjectMetadata
)?.nameSingular;
const relationFieldMetadataId =
fieldDefinition.toRelationMetadata?.fromFieldMetadataId ||
fieldDefinition.fromRelationMetadata?.toFieldMetadataId;
if (!relationObjectMetadataNameSingular || !relationFieldMetadataId)
return result;
const relationObjectMetadataItem = snapshot
.getLoadable(
objectMetadataItemFamilySelector({
objectName: relationObjectMetadataNameSingular,
objectNameType: 'singular',
}),
)
.valueOrThrow();
if (!relationObjectMetadataItem) return result;
const relationFieldName = relationObjectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === relationFieldMetadataId,
)?.name;
if (!relationFieldName) return result;
return [
...result,
{
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord: cachedRecord[fieldName],
nextRelationRecord,
},
];
}, []),
);
@@ -4,15 +4,24 @@ import { z } from 'zod';
import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isCachedObjectConnection = (
export const isCachedObjectRecordConnection = (
objectNameSingular: string,
storeValue: StoreValue,
): storeValue is CachedObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const cachedObjectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object({
__ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`),
}),
}),
),
});
const cachedConnectionValidation =
cachedObjectConnectionSchema.safeParse(storeValue);
@@ -0,0 +1,28 @@
import { z } from 'zod';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const isObjectRecordConnection = (
objectNameSingular: string,
value: unknown,
): value is ObjectRecordConnection => {
const objectConnectionTypeName = `${capitalize(
objectNameSingular,
)}Connection`;
const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`;
const objectConnectionSchema = z.object({
__typename: z.literal(objectConnectionTypeName),
edges: z.array(
z.object({
__typename: z.literal(objectEdgeTypeName),
node: z.object({
id: z.string().uuid(),
}),
}),
),
});
const connectionValidation = objectConnectionSchema.safeParse(value);
return connectionValidation.success;
};
@@ -0,0 +1,59 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { capitalize } from '~/utils/string/capitalize';
export const triggerAttachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
}) => {
const recordTypeName = capitalize(objectNameSingular);
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { toReference }) => {
const nodeReference = toReference({
id: recordId,
__typename: recordTypeName,
});
if (!nodeReference) return cachedFieldValue;
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
// To many objects => add record to next relation field list
const nextEdges: CachedObjectRecordEdge[] = [
...cachedFieldValue.edges,
{
__typename: `${recordTypeName}Edge`,
node: nodeReference,
cursor: '',
},
];
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => attach next relation record
return nodeReference;
},
},
});
};
@@ -1,8 +1,10 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { capitalize } from '~/utils/string/capitalize';
@@ -15,15 +17,27 @@ export const triggerCreateRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
records.forEach((record) =>
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: null,
nextRecord: record,
getRelationMetadata,
}),
);
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
@@ -36,7 +50,7 @@ export const triggerCreateRecordsOptimisticEffect = ({
},
) => {
if (
!isCachedObjectConnection(
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
@@ -1,9 +1,11 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isDefined } from '~/utils/isDefined';
import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName';
@@ -12,10 +14,12 @@ export const triggerDeleteRecordsOptimisticEffect = ({
cache,
objectMetadataItem,
records,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
records: Pick<CachedObjectRecord, 'id' | '__typename'>[];
records: CachedObjectRecord[];
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
cache.modify<StoreObject>({
fields: {
@@ -24,7 +28,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({
{ DELETE, readField, storeFieldName },
) => {
if (
!isCachedObjectConnection(
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
@@ -64,5 +68,15 @@ export const triggerDeleteRecordsOptimisticEffect = ({
},
});
records.forEach((record) => cache.evict({ id: cache.identify(record) }));
records.forEach((record) => {
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord: record,
nextRecord: null,
getRelationMetadata,
});
cache.evict({ id: cache.identify(record) });
});
};
@@ -0,0 +1,49 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { capitalize } from '~/utils/string/capitalize';
export const triggerDetachRelationOptimisticEffect = ({
cache,
objectNameSingular,
recordId,
relationObjectMetadataNameSingular,
relationFieldName,
relationRecordId,
}: {
cache: ApolloCache<unknown>;
objectNameSingular: string;
recordId: string;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
relationRecordId: string;
}) => {
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
cache.modify<StoreObject>({
id: cache.identify({
id: relationRecordId,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { isReference, readField }) => {
// To many objects => remove record from previous relation field list
if (
isCachedObjectRecordConnection(objectNameSingular, cachedFieldValue)
) {
const nextEdges = cachedFieldValue.edges.filter(
({ node }) => readField('id', node) !== recordId,
);
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => detach previous relation record
if (isReference(cachedFieldValue)) {
return null;
}
return cachedFieldValue;
},
},
});
};
@@ -1,10 +1,12 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection';
import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges';
import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter';
import { isDefined } from '~/utils/isDefined';
@@ -14,16 +16,29 @@ import { capitalize } from '~/utils/string/capitalize';
export const triggerUpdateRecordOptimisticEffect = ({
cache,
objectMetadataItem,
record,
previousRecord,
nextRecord,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
record: CachedObjectRecord;
previousRecord: CachedObjectRecord;
nextRecord: CachedObjectRecord;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) => {
const objectEdgeTypeName = `${capitalize(
objectMetadataItem.nameSingular,
)}Edge`;
triggerUpdateRelationsOptimisticEffect({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
});
// Optimistically update record lists
cache.modify<StoreObject>({
fields: {
[objectMetadataItem.namePlural]: (
@@ -31,7 +46,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
{ DELETE, readField, storeFieldName, toReference },
) => {
if (
!isCachedObjectConnection(
!isCachedObjectRecordConnection(
objectMetadataItem.nameSingular,
cachedConnection,
)
@@ -49,18 +64,20 @@ export const triggerUpdateRecordOptimisticEffect = ({
);
let nextCachedEdges = cachedEdges ? [...cachedEdges] : [];
// Test if the record matches this list's filters
if (variables?.filter) {
const matchesFilter = isRecordMatchingFilter({
record,
record: nextRecord,
filter: variables.filter,
objectMetadataItem,
});
const recordIndex = nextCachedEdges.findIndex(
(cachedEdge) => readField('id', cachedEdge.node) === record.id,
(cachedEdge) => readField('id', cachedEdge.node) === nextRecord.id,
);
// If after update, the record matches this list's filters, then add it to the list
if (matchesFilter && recordIndex === -1) {
const nodeReference = toReference(record);
const nodeReference = toReference(nextRecord);
nodeReference &&
nextCachedEdges.push({
__typename: objectEdgeTypeName,
@@ -69,11 +86,13 @@ export const triggerUpdateRecordOptimisticEffect = ({
});
}
// If after update, the record does not match this list's filters anymore, then remove it from the list
if (!matchesFilter && recordIndex > -1) {
nextCachedEdges.splice(recordIndex, 1);
}
}
// Sort updated list
if (variables?.orderBy) {
nextCachedEdges = sortCachedObjectEdges({
edges: nextCachedEdges,
@@ -82,6 +101,7 @@ export const triggerUpdateRecordOptimisticEffect = ({
});
}
// Limit the updated list to the required size
if (isDefined(variables?.first)) {
// If previous edges length was exactly at the required limit,
// but after update next edges length is under the limit,
@@ -1,86 +0,0 @@
import { ApolloCache, StoreObject } from '@apollo/client';
import { isCachedObjectConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectConnection';
import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { capitalize } from '~/utils/string/capitalize';
export type TriggerUpdateRelationFieldOptimisticEffectParams = {
cache: ApolloCache<unknown>;
objectNameSingular: string;
record: ObjectRecord;
relationObjectMetadataNameSingular: string;
relationFieldName: string;
previousRelationRecord: ObjectRecord | null;
nextRelationRecord: ObjectRecord | null;
};
export const triggerUpdateRelationFieldOptimisticEffect = ({
cache,
objectNameSingular,
record,
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}: TriggerUpdateRelationFieldOptimisticEffectParams) => {
const recordTypeName = capitalize(objectNameSingular);
const relationRecordTypeName = capitalize(relationObjectMetadataNameSingular);
if (previousRelationRecord) {
cache.modify<StoreObject>({
id: cache.identify({
...previousRelationRecord,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { isReference, readField }) => {
// To many objects => remove record from previous relation field list
if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) {
const nextEdges = cachedFieldValue.edges.filter(
({ node }) => readField('id', node) !== record.id,
);
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => detach previous relation record
if (isReference(cachedFieldValue)) {
return null;
}
},
},
});
}
if (nextRelationRecord) {
cache.modify<StoreObject>({
id: cache.identify({
...nextRelationRecord,
__typename: relationRecordTypeName,
}),
fields: {
[relationFieldName]: (cachedFieldValue, { toReference }) => {
const nodeReference = toReference(record);
if (!nodeReference) return cachedFieldValue;
if (isCachedObjectConnection(objectNameSingular, cachedFieldValue)) {
// To many objects => add record to next relation field list
const nextEdges: CachedObjectRecordEdge[] = [
...cachedFieldValue.edges,
{
__typename: `${recordTypeName}Edge`,
node: nodeReference,
cursor: '',
},
];
return { ...cachedFieldValue, edges: nextEdges };
}
// To one object => attach next relation record
return nodeReference;
},
},
});
}
};
@@ -0,0 +1,112 @@
import { ApolloCache } from '@apollo/client';
import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection';
import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect';
import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord';
import { coreObjectNamesToDeleteOnRelationDetach } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isDefined } from '~/utils/isDefined';
export const triggerUpdateRelationsOptimisticEffect = ({
cache,
objectMetadataItem,
previousRecord,
nextRecord,
getRelationMetadata,
}: {
cache: ApolloCache<unknown>;
objectMetadataItem: ObjectMetadataItem;
previousRecord: CachedObjectRecord | null;
nextRecord: CachedObjectRecord | null;
getRelationMetadata: ReturnType<typeof useGetRelationMetadata>;
}) =>
// Optimistically update relation records
objectMetadataItem.fields.forEach((fieldMetadataItem) => {
if (nextRecord && !(fieldMetadataItem.name in nextRecord)) return;
const relationMetadata = getRelationMetadata({
fieldMetadataItem,
});
if (!relationMetadata) return;
const {
// Object metadata for the related record
relationObjectMetadataItem,
// Field on the related record
relationFieldMetadataItem,
} = relationMetadata;
const previousFieldValue:
| ObjectRecordConnection
| CachedObjectRecord
| null = previousRecord?.[fieldMetadataItem.name];
const nextFieldValue: ObjectRecordConnection | CachedObjectRecord | null =
nextRecord?.[fieldMetadataItem.name];
if (isDeeplyEqual(previousFieldValue, nextFieldValue)) return;
const isPreviousFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
previousFieldValue,
);
const relationRecordsToDetach = isPreviousFieldValueRecordConnection
? previousFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [previousFieldValue].filter(isDefined);
const isNextFieldValueRecordConnection = isObjectRecordConnection(
relationObjectMetadataItem.nameSingular,
nextFieldValue,
);
const relationRecordsToAttach = isNextFieldValueRecordConnection
? nextFieldValue.edges.map(({ node }) => node as CachedObjectRecord)
: [nextFieldValue].filter(isDefined);
if (previousRecord && relationRecordsToDetach.length) {
const shouldDeleteRelationRecord =
coreObjectNamesToDeleteOnRelationDetach.includes(
relationObjectMetadataItem.nameSingular as CoreObjectNameSingular,
);
if (shouldDeleteRelationRecord) {
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem: relationObjectMetadataItem,
records: relationRecordsToDetach,
getRelationMetadata,
});
} else {
relationRecordsToDetach.forEach((relationRecordToDetach) => {
triggerDetachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: previousRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToDetach.id,
});
});
}
}
if (nextRecord && relationRecordsToAttach.length) {
relationRecordsToAttach.forEach((relationRecordToAttach) =>
triggerAttachRelationOptimisticEffect({
cache,
objectNameSingular: objectMetadataItem.nameSingular,
recordId: nextRecord.id,
relationFieldName: relationFieldMetadataItem.name,
relationObjectMetadataNameSingular:
relationObjectMetadataItem.nameSingular,
relationRecordId: relationRecordToAttach.id,
}),
);
}
});
@@ -0,0 +1,5 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
export const coreObjectNamesToDeleteOnRelationDetach = [
CoreObjectNameSingular.Favorite,
];
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer';
import { Activity } from '@/activities/types/Activity';
@@ -124,7 +125,7 @@ export const CommandMenu = () => {
);
useScopedHotkeys(
'esc',
[Key.Escape],
() => {
closeCommandMenu();
},
@@ -328,7 +329,7 @@ export const CommandMenu = () => {
<Avatar
type="rounded"
avatarUrl={null}
colorId={person.id}
entityId={person.id}
placeholder={
person.name.firstName +
' ' +
@@ -350,7 +351,7 @@ export const CommandMenu = () => {
to={`object/company/${company.id}`}
Icon={() => (
<Avatar
colorId={company.id}
entityId={company.id}
placeholder={company.name}
avatarUrl={getLogoUrlFromDomainName(
company.domainName,
@@ -46,7 +46,7 @@ export const Favorites = () => {
label={labelIdentifier}
Icon={() => (
<Avatar
colorId={recordId}
entityId={recordId}
avatarUrl={avatarUrl}
type={avatarType}
placeholder={labelIdentifier}
@@ -1,4 +1,5 @@
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
@@ -22,6 +23,7 @@ export const KeyboardShortcutMenu = () => {
isKeyboardShortcutMenuOpenedState,
);
const { closeCommandMenu } = useCommandMenu();
useScopedHotkeys(
'shift+?,meta+?',
() => {
@@ -33,12 +35,12 @@ export const KeyboardShortcutMenu = () => {
);
useScopedHotkeys(
'esc',
[Key.Escape],
() => {
closeKeyboardShortcutMenu();
},
AppHotkeyScope.KeyboardShortcutMenu,
[toggleKeyboardShortcutMenu],
AppHotkeyScope.KeyboardShortcutMenuOpen,
[closeKeyboardShortcutMenu],
);
return (
@@ -46,8 +48,8 @@ export const KeyboardShortcutMenu = () => {
{isKeyboardShortcutMenuOpened && (
<KeyboardMenuDialog onClose={toggleKeyboardShortcutMenu}>
<KeyboardMenuGroup heading="Table">
{keyboardShortcutsTable.map((TableShortcut) => (
<KeyboardMenuItem shortcut={TableShortcut} />
{keyboardShortcutsTable.map((TableShortcut, index) => (
<KeyboardMenuItem shortcut={TableShortcut} key={index} />
))}
</KeyboardMenuGroup>
<KeyboardMenuGroup heading="General">
@@ -0,0 +1,70 @@
import { ReactNode, useEffect } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot, useSetRecoilState } from 'recoil';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<TestApolloMetadataClientProvider>
{children}
</TestApolloMetadataClientProvider>
</MockedProvider>
</RecoilRoot>
);
describe('useGetRelationMetadata', () => {
it('should return correct properties', async () => {
const objectMetadataItems = getObjectMetadataItemsMock();
const objectMetadata = objectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const fieldMetadataItem = objectMetadata.fields.find(
(field) => field.name === 'opportunities',
)!;
const { result } = renderHook(
() => {
const setMetadataItems = useSetRecoilState(objectMetadataItemsState);
useEffect(() => {
setMetadataItems(objectMetadataItems);
}, [setMetadataItems]);
return useGetRelationMetadata();
},
{
wrapper: Wrapper,
initialProps: {},
},
);
const {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
} = result.current({ fieldMetadataItem }) ?? {};
const expectedRelationObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === 'opportunity',
);
const expectedRelationFieldMetadataItem =
expectedRelationObjectMetadataItem?.fields.find(
(field) => field.name === 'person',
);
expect(relationObjectMetadataItem).toEqual(
expectedRelationObjectMetadataItem,
);
expect(relationFieldMetadataItem).toEqual(
expectedRelationFieldMetadataItem,
);
expect(relationType).toBe('ONE_TO_MANY');
});
});
@@ -1,55 +0,0 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { useRelationMetadata } from '@/object-metadata/hooks/useRelationMetadata';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock';
import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider';
const Wrapper = ({ children }: { children: ReactNode }) => (
<RecoilRoot>
<MockedProvider addTypename={false}>
<TestApolloMetadataClientProvider>
{children}
</TestApolloMetadataClientProvider>
</MockedProvider>
</RecoilRoot>
);
describe('useRelationMetadata', () => {
it('should return correct properties', async () => {
const { result, rerender } = renderHook(
({ fieldMetadataItem }: { fieldMetadataItem?: FieldMetadataItem }) =>
useRelationMetadata({ fieldMetadataItem }),
{
wrapper: Wrapper,
initialProps: {},
},
);
const {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
} = result.current;
expect(relationFieldMetadataItem).toBeUndefined();
expect(relationObjectMetadataItem).toBeUndefined();
expect(relationType).toBeUndefined();
const objectMetadataItems = getObjectMetadataItemsMock();
const objectMetadata = objectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const fieldMetadataItem = objectMetadata.fields.find(
(field) => field.name === 'opportunities',
)!;
rerender({ fieldMetadataItem });
expect(result.current.relationType).toBe('ONE_TO_MANY');
});
});
@@ -0,0 +1,67 @@
import { useRecoilCallback } from 'recoil';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { RelationType } from '@/settings/data-model/types/RelationType';
import {
FieldMetadataType,
RelationMetadataType,
} from '~/generated-metadata/graphql';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
export const useGetRelationMetadata = () =>
useRecoilCallback(
({ snapshot }) =>
({ fieldMetadataItem }: { fieldMetadataItem: FieldMetadataItem }) => {
if (fieldMetadataItem.type !== FieldMetadataType.Relation) return null;
const relationMetadata =
fieldMetadataItem.fromRelationMetadata ||
fieldMetadataItem.toRelationMetadata;
if (!relationMetadata) return null;
const relationFieldMetadataId =
'toFieldMetadataId' in relationMetadata
? relationMetadata.toFieldMetadataId
: relationMetadata.fromFieldMetadataId;
if (!relationFieldMetadataId) return null;
const relationType =
relationMetadata.relationType === RelationMetadataType.OneToMany &&
fieldMetadataItem.toRelationMetadata
? 'MANY_TO_ONE'
: (relationMetadata.relationType as RelationType);
const relationObjectMetadataNameSingular =
'toObjectMetadata' in relationMetadata
? relationMetadata.toObjectMetadata.nameSingular
: relationMetadata.fromObjectMetadata.nameSingular;
const relationObjectMetadataItem = snapshot
.getLoadable(
objectMetadataItemFamilySelector({
objectName: relationObjectMetadataNameSingular,
objectNameType: 'singular',
}),
)
.valueOrThrow();
if (!relationObjectMetadataItem) return null;
const relationFieldMetadataItem =
relationObjectMetadataItem.fields.find(
(field) => field.id === relationFieldMetadataId,
);
if (!relationFieldMetadataItem) return null;
return {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
};
},
[],
);
@@ -1,49 +0,0 @@
import { RelationType } from '@/settings/data-model/types/RelationType';
import { RelationMetadataType } from '~/generated-metadata/graphql';
import { useObjectMetadataItemForSettings } from '../hooks/useObjectMetadataItemForSettings';
import { FieldMetadataItem } from '../types/FieldMetadataItem';
export const useRelationMetadata = ({
fieldMetadataItem,
}: {
fieldMetadataItem?: FieldMetadataItem;
}) => {
const { findObjectMetadataItemById } = useObjectMetadataItemForSettings();
const relationMetadata =
fieldMetadataItem?.fromRelationMetadata ||
fieldMetadataItem?.toRelationMetadata;
const relationType =
relationMetadata?.relationType === RelationMetadataType.OneToMany &&
fieldMetadataItem?.toRelationMetadata
? 'MANY_TO_ONE'
: (relationMetadata?.relationType as RelationType | undefined);
const relationObjectMetadataId =
relationMetadata && 'toObjectMetadata' in relationMetadata
? relationMetadata.toObjectMetadata.id
: relationMetadata?.fromObjectMetadata.id;
const relationObjectMetadataItem = relationObjectMetadataId
? findObjectMetadataItemById(relationObjectMetadataId)
: undefined;
const relationFieldMetadataId =
relationMetadata && 'toFieldMetadataId' in relationMetadata
? relationMetadata.toFieldMetadataId
: relationMetadata?.fromFieldMetadataId;
const relationFieldMetadataItem = relationFieldMetadataId
? relationObjectMetadataItem?.fields?.find(
(field) => field.id === relationFieldMetadataId,
)
: undefined;
return {
relationFieldMetadataItem,
relationObjectMetadataItem,
relationType,
};
};
@@ -52,10 +52,13 @@ export const getObjectRecordIdentifier = ({
? 'squared'
: 'rounded';
// TODO: This is a temporary solution before we seed imageIdentifierFieldMetadataId in the database
const avatarUrl =
(objectMetadataItem.nameSingular === CoreObjectNameSingular.Company
? getLogoUrlFromDomainName(record['domainName'] ?? '')
: imageIdentifierFieldValue) ?? '';
: objectMetadataItem.nameSingular === CoreObjectNameSingular.Person
? record['avatarUrl'] ?? ''
: imageIdentifierFieldValue) ?? '';
const basePathToShowPage = getBasePathToShowPage({
objectMetadataItem,
@@ -1,41 +0,0 @@
import { v4 } from 'uuid';
import { z } from 'zod';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateCachedObjectRecord = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const generateCachedObjectRecord = <
GeneratedObjectRecord extends ObjectRecord,
>(
input: Record<string, unknown>,
) => {
const recordSchema = z.object(
Object.fromEntries(
objectMetadataItem.fields.map((fieldMetadataItem) => [
fieldMetadataItem.name,
z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)),
]),
),
);
return {
__typename: capitalize(objectMetadataItem.nameSingular),
...recordSchema.parse({
id: v4(),
createdAt: new Date().toISOString(),
...input,
}),
} as GeneratedObjectRecord & { __typename: string };
};
return {
generateCachedObjectRecord,
};
};
@@ -0,0 +1,72 @@
import { v4 } from 'uuid';
import { z } from 'zod';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue';
import { capitalize } from '~/utils/string/capitalize';
export const useGenerateObjectRecordOptimisticResponse = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const getRelationMetadata = useGetRelationMetadata();
const generateObjectRecordOptimisticResponse = <
GeneratedObjectRecord extends ObjectRecord,
>(
input: Record<string, unknown>,
) => {
const recordSchema = z.object(
Object.fromEntries(
objectMetadataItem.fields.map((fieldMetadataItem) => [
fieldMetadataItem.name,
z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)),
]),
),
);
const inputWithRelationFields = objectMetadataItem.fields.reduce(
(result, fieldMetadataItem) => {
const relationIdFieldName = `${fieldMetadataItem.name}Id`;
if (!(relationIdFieldName in input)) return result;
const relationMetadata = getRelationMetadata({ fieldMetadataItem });
if (!relationMetadata) return result;
const relationRecordTypeName = capitalize(
relationMetadata.relationObjectMetadataItem.nameSingular,
);
const relationRecordId = result[relationIdFieldName] as string | null;
return {
...result,
[fieldMetadataItem.name]: relationRecordId
? {
__typename: relationRecordTypeName,
id: relationRecordId,
}
: null,
};
},
input,
);
return {
__typename: capitalize(objectMetadataItem.nameSingular),
...recordSchema.parse({
id: v4(),
createdAt: new Date().toISOString(),
...inputWithRelationFields,
}),
} as GeneratedObjectRecord & { __typename: string };
};
return {
generateObjectRecordOptimisticResponse,
};
};
@@ -15,6 +15,7 @@ export const useGetRecordFromCache = ({
return <CachedObjectRecord extends ObjectRecord = ObjectRecord>(
recordId: string,
cache = apolloClient.cache,
) => {
if (!objectMetadataItem) {
return null;
@@ -31,7 +32,6 @@ export const useGetRecordFromCache = ({
}
`;
const cache = apolloClient.cache;
const cachedRecordId = cache.identify({
__typename: capitalize(objectMetadataItem.nameSingular),
id: recordId,
@@ -9,12 +9,14 @@ export type RecordChipProps = {
objectNameSingular: string;
record: ObjectRecord;
maxWidth?: number;
className?: string;
};
export const RecordChip = ({
objectNameSingular,
record,
maxWidth,
className,
}: RecordChipProps) => {
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
@@ -34,6 +36,7 @@ export const RecordChip = ({
avatarUrl={objectRecordIdentifier.avatarUrl}
linkToEntity={objectRecordIdentifier.linkToShowPage}
maxWidth={maxWidth}
className={className}
/>
);
};
@@ -1,5 +1,7 @@
import { gql } from '@apollo/client';
import { Person } from '@/people/types/Person';
export const query = gql`
mutation CreatePeople($data: [PersonCreateInput!]!) {
createPeople(data: $data) {
@@ -67,12 +69,15 @@ export const query = gql`
}
`;
export const variables = {
data: [
{ id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' },
],
};
const data = [
{
id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9',
name: { firstName: 'John', lastName: 'Doe' },
},
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0', jobTitle: 'manager' },
] satisfies Partial<Person>[];
export const variables = { data };
export const responseData = {
opportunities: {
@@ -114,3 +119,8 @@ export const responseData = {
avatarUrl: '',
companyId: '',
};
export const response = data.map((personData) => ({
...responseData,
...personData,
}));
@@ -67,10 +67,6 @@ export const query = gql`
}
`;
export const variables = {
input: { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
};
export const responseData = {
opportunities: {
edges: [],
@@ -1,19 +1,27 @@
import { ReactNode } from 'react';
import { MockedProvider } from '@apollo/client/testing';
import { mocked } from '@storybook/test';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { v4 } from 'uuid';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
query,
responseData,
response,
variables,
} from '@/object-record/hooks/__mocks__/useCreateManyRecords';
import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords';
const people = [
{ id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' },
{ id: '37faabcd-cb39-4a0a-8618-7e3fda9afca0' },
];
jest.mock('uuid', () => ({
v4: jest.fn(),
}));
mocked(v4)
.mockReturnValueOnce(variables.data[0].id)
.mockReturnValueOnce(variables.data[1].id);
const input = variables.data.map(({ id: _id, ...personInput }) => personInput);
const mocks = [
{
@@ -23,10 +31,7 @@ const mocks = [
},
result: jest.fn(() => ({
data: {
createPeople: people.map((person) => ({
id: person.id,
...responseData,
})),
createPeople: response,
},
})),
},
@@ -43,19 +48,18 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useCreateManyRecords', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => useCreateManyRecords({ objectNameSingular: 'person' }),
() =>
useCreateManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
}),
{
wrapper: Wrapper,
},
);
await act(async () => {
const res = await result.current.createManyRecords(people);
expect(res).toBeDefined();
expect(Array.isArray(res)).toBe(true);
expect(res?.length).toBe(2);
expect(res?.[0].id).toBe(people[0].id);
expect(res?.[1].id).toBe(people[1].id);
const res = await result.current.createManyRecords(input);
expect(res).toEqual(response);
});
expect(mocks[0].result).toHaveBeenCalled();
@@ -3,24 +3,29 @@ import { MockedProvider } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import {
query,
responseData,
variables,
} from '@/object-record/hooks/__mocks__/useCreateOneRecord';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
const person = { id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9' };
const personId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9';
const input = { name: { firstName: 'John', lastName: 'Doe' } };
jest.mock('uuid', () => ({
v4: jest.fn(() => personId),
}));
const mocks = [
{
request: {
query,
variables,
variables: { input: { ...input, id: personId } },
},
result: jest.fn(() => ({
data: {
createPerson: { ...person, ...responseData },
createPerson: { ...responseData, ...input, id: personId },
},
})),
},
@@ -37,16 +42,20 @@ const Wrapper = ({ children }: { children: ReactNode }) => (
describe('useCreateOneRecord', () => {
it('works as expected', async () => {
const { result } = renderHook(
() => useCreateOneRecord({ objectNameSingular: 'person' }),
() =>
useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
}),
{
wrapper: Wrapper,
},
);
await act(async () => {
const res = await result.current.createOneRecord(person);
const res = await result.current.createOneRecord(input);
console.log('res', res);
expect(res).toBeDefined();
expect(res).toHaveProperty('id', person.id);
expect(res).toHaveProperty('id', personId);
});
expect(mocks[0].result).toHaveBeenCalled();
@@ -1,9 +1,11 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@@ -13,29 +15,33 @@ export const useCreateManyRecords = <
>({
objectNameSingular,
}: ObjectMetadataItemIdentifier) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createManyRecordsMutation } =
useObjectMetadataItem({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const getRelationMetadata = useGetRelationMetadata();
const createManyRecords = async (data: Partial<CreatedObjectRecord>[]) => {
const optimisticallyCreatedRecords = data.map((record) =>
generateCachedObjectRecord<CreatedObjectRecord>(record),
);
const sanitizedCreateManyRecordsInput = data.map((input, index) =>
const sanitizedCreateManyRecordsInput = data.map((input) =>
sanitizeRecordInput({
objectMetadataItem,
recordInput: { ...input, id: optimisticallyCreatedRecords[index].id },
recordInput: { ...input, id: v4() },
}),
);
const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map(
(record) =>
generateObjectRecordOptimisticResponse<CreatedObjectRecord>(record),
);
const mutationResponseField = getCreateManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
@@ -57,6 +63,7 @@ export const useCreateManyRecords = <
cache,
objectMetadataItem,
records,
getRelationMetadata,
});
},
});
@@ -3,7 +3,7 @@ import { v4 } from 'uuid';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier';
import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
@@ -13,9 +13,10 @@ export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
@@ -30,9 +31,8 @@ export const useCreateManyRecordsInCache = <T extends ObjectRecord>({
const createdRecordsInCache = [] as T[];
for (const record of recordsWithId) {
const generatedCachedObjectRecord = generateCachedObjectRecord<T>({
...record,
});
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse<T>(record);
if (generatedCachedObjectRecord) {
addRecordInCache(generatedCachedObjectRecord);
@@ -1,8 +1,10 @@
import { useApolloClient } from '@apollo/client';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
@@ -16,25 +18,27 @@ export const useCreateOneRecord = <
>({
objectNameSingular,
}: useCreateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem(
{ objectNameSingular },
);
// TODO: type this with a minimal type at least with Record<string, any>
const apolloClient = useApolloClient();
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const getRelationMetadata = useGetRelationMetadata();
const createOneRecord = async (input: Partial<CreatedObjectRecord>) => {
const sanitizedCreateOneRecordInput = sanitizeRecordInput({
objectMetadataItem,
recordInput: input,
recordInput: { ...input, id: v4() },
});
const optimisticallyCreatedRecord =
generateCachedObjectRecord<CreatedObjectRecord>({
generateObjectRecordOptimisticResponse<CreatedObjectRecord>({
...input,
...sanitizedCreateOneRecordInput,
});
@@ -45,10 +49,7 @@ export const useCreateOneRecord = <
const createdObject = await apolloClient.mutate({
mutation: createOneRecordMutation,
variables: {
input: {
...sanitizedCreateOneRecordInput,
id: optimisticallyCreatedRecord.id,
},
input: sanitizedCreateOneRecordInput,
},
optimisticResponse: {
[mutationResponseField]: optimisticallyCreatedRecord,
@@ -62,6 +63,7 @@ export const useCreateOneRecord = <
cache,
objectMetadataItem,
records: [record],
getRelationMetadata,
});
},
});
@@ -1,6 +1,6 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache';
import { useGenerateCachedObjectRecord } from '@/object-record/cache/hooks/useGenerateCachedObjectRecord';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type useCreateOneRecordInCacheProps = {
@@ -14,19 +14,18 @@ export const useCreateOneRecordInCache = <T>({
objectNameSingular,
});
const { generateCachedObjectRecord } = useGenerateCachedObjectRecord({
objectMetadataItem,
});
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const addRecordInCache = useAddRecordInCache({
objectMetadataItem,
});
const createOneRecordInCache = async (input: ObjectRecord) => {
const generatedCachedObjectRecord = generateCachedObjectRecord({
createdAt: new Date().toISOString(),
...input,
});
const generatedCachedObjectRecord =
generateObjectRecordOptimisticResponse(input);
addRecordInCache(generatedCachedObjectRecord);
@@ -1,9 +1,10 @@
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation';
import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter';
import { isDefined } from '~/utils/isDefined';
import { capitalize } from '~/utils/string/capitalize';
type useDeleteOneRecordProps = {
@@ -14,26 +15,22 @@ type useDeleteOneRecordProps = {
export const useDeleteManyRecords = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const { objectMetadataItem, deleteManyRecordsMutation } =
const apolloClient = useApolloClient();
const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const apolloClient = useApolloClient();
const getRelationMetadata = useGetRelationMetadata();
const mutationResponseField = getDeleteManyRecordsMutationResponseField(
objectMetadataItem.namePlural,
);
const deleteManyRecords = async (idsToDelete: string[]) => {
const deleteRecordFilter: ObjectRecordQueryFilter = {
id: {
in: idsToDelete,
},
};
const deletedRecords = await apolloClient.mutate({
mutation: deleteManyRecordsMutation,
variables: {
filter: deleteRecordFilter,
// atMost: idsToDelete.length,
filter: { id: { in: idsToDelete } },
},
optimisticResponse: {
[mutationResponseField]: idsToDelete.map((idToDelete) => ({
@@ -46,10 +43,15 @@ export const useDeleteManyRecords = ({
if (!records?.length) return;
const cachedRecords = records
.map((record) => getRecordFromCache(record.id, cache))
.filter(isDefined);
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records,
records: cachedRecords,
getRelationMetadata,
});
},
});
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation';
import { capitalize } from '~/utils/string/capitalize';
@@ -14,12 +15,13 @@ type useDeleteOneRecordProps = {
export const useDeleteOneRecord = ({
objectNameSingular,
}: useDeleteOneRecordProps) => {
const { objectMetadataItem, deleteOneRecordMutation } = useObjectMetadataItem(
{ objectNameSingular },
);
const apolloClient = useApolloClient();
const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationMetadata = useGetRelationMetadata();
const mutationResponseField =
getDeleteOneRecordMutationResponseField(objectNameSingular);
@@ -39,10 +41,15 @@ export const useDeleteOneRecord = ({
if (!record) return;
const cachedRecord = getRecordFromCache(record.id, cache);
if (!cachedRecord) return;
triggerDeleteRecordsOptimisticEffect({
cache,
objectMetadataItem,
records: [record],
records: [cachedRecord],
getRelationMetadata,
});
},
});
@@ -52,6 +59,8 @@ export const useDeleteOneRecord = ({
[
apolloClient,
deleteOneRecordMutation,
getRecordFromCache,
getRelationMetadata,
mutationResponseField,
objectMetadataItem,
objectNameSingular,
@@ -1,13 +1,12 @@
import { useApolloClient } from '@apollo/client';
import { useGetRelationFieldsToOptimisticallyUpdate } from '@/apollo/optimistic-effect/hooks/useGetRelationFieldsToOptimisticallyUpdate';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { triggerUpdateRelationFieldOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationFieldOptimisticEffect';
import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse';
import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
import { capitalize } from '~/utils/string/capitalize';
type useUpdateOneRecordProps = {
objectNameSingular: string;
@@ -18,13 +17,17 @@ export const useUpdateOneRecord = <
>({
objectNameSingular,
}: useUpdateOneRecordProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } =
useObjectMetadataItem({ objectNameSingular });
const getRelationFieldsToOptimisticallyUpdate =
useGetRelationFieldsToOptimisticallyUpdate();
const { generateObjectRecordOptimisticResponse } =
useGenerateObjectRecordOptimisticResponse({
objectMetadataItem,
});
const apolloClient = useApolloClient();
const getRelationMetadata = useGetRelationMetadata();
const updateOneRecord = async ({
idToUpdate,
@@ -40,21 +43,11 @@ export const useUpdateOneRecord = <
recordInput: updateOneRecordInput,
});
const optimisticallyUpdatedRecord = {
const optimisticallyUpdatedRecord = generateObjectRecordOptimisticResponse({
...(cachedRecord ?? {}),
...updateOneRecordInput,
...sanitizedUpdateOneRecordInput,
__typename: capitalize(objectNameSingular),
id: idToUpdate,
};
const updatedRelationFields = cachedRecord
? getRelationFieldsToOptimisticallyUpdate({
cachedRecord,
objectMetadataItem,
updateRecordInput: updateOneRecordInput,
})
: [];
});
const mutationResponseField =
getUpdateOneRecordMutationResponseField(objectNameSingular);
@@ -71,31 +64,15 @@ export const useUpdateOneRecord = <
update: (cache, { data }) => {
const record = data?.[mutationResponseField];
if (!record) return;
if (!record || !cachedRecord) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
record,
previousRecord: cachedRecord,
nextRecord: record,
getRelationMetadata,
});
updatedRelationFields.forEach(
({
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}) =>
triggerUpdateRelationFieldOptimisticEffect({
cache,
objectNameSingular,
record,
relationObjectMetadataNameSingular,
relationFieldName,
previousRelationRecord,
nextRelationRecord,
}),
);
},
});
@@ -12,9 +12,7 @@ import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-boa
import { useSetRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal';
import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope';
import { Opportunity } from '@/pipeline/types/Opportunity';
import { PageHotkeyScope } from '@/types/PageHotkeyScope';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { logError } from '~/utils/logError';
@@ -129,12 +127,6 @@ export const RecordBoardDeprecated = ({
const boardRef = useRef<HTMLDivElement>(null);
useScopedHotkeys(
'escape',
unselectAllActiveCards,
PageHotkeyScope.OpportunitiesPage,
);
return (
<RecordBoardDeprecatedScope recordBoardScopeId={recordBoardId}>
<RecordBoardDeprecatedContextMenu />
@@ -69,7 +69,9 @@ export const RecordBoardDeprecatedColumnDropdownMenu = ({
useScopedHotkeys(
[Key.Escape, Key.Enter],
closeMenu,
() => {
closeMenu();
},
BoardColumnHotkeyScope.BoardColumn,
[],
);
@@ -123,7 +123,7 @@ export const RecordBoardDeprecatedOptionsDropdownContent = ({
);
useScopedHotkeys(
Key.Escape,
[Key.Escape],
() => {
setViewEditMode('none');
closeDropdown();
@@ -2,15 +2,19 @@ import { useContext, useRef } from 'react';
import styled from '@emotion/styled';
import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn';
import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope';
import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope';
import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { FieldMetadataType } from '~/generated-metadata/graphql';
export type RecordBoardProps = {
recordBoardId: string;
@@ -40,17 +44,25 @@ const StyledBoardHeader = styled.div`
`;
export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
const { updateOneRecord, objectMetadataItem } =
const { updateOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const boardRef = useRef<HTMLDivElement>(null);
const { getColumnIdsState, columnsFamilySelector } =
useRecordBoardStates(recordBoardId);
const columnIds = useRecoilValue(getColumnIdsState());
const selectFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.type === FieldMetadataType.Select,
);
const { resetRecordSelection, setRecordAsSelected } =
useRecordBoardSelection(recordBoardId);
useListenClickOutsideByClassName({
classNames: ['record-board-card'],
excludeClassNames: ['action-bar', 'context-menu'],
callback: resetRecordSelection,
});
useScopedHotkeys([Key.Escape], resetRecordSelection, TableHotkeyScope.Table);
const onDragEnd: OnDragEndResponder = useRecoilCallback(
({ snapshot }) =>
@@ -106,7 +118,8 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => {
</ScrollWrapper>
<DragSelect
dragSelectable={boardRef}
onDragSelectionChange={() => {}}
onDragSelectionStart={resetRecordSelection}
onDragSelectionChange={setRecordAsSelected}
/>
</StyledWrapper>
</RecordBoardScope>
@@ -1,10 +1,12 @@
import { createContext } from 'react';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
type RecordBoardContextProps = {
objectMetadataItem: ObjectMetadataItem;
selectFieldMetadataItem: FieldMetadataItem;
createOneRecord: (recordInput: Partial<ObjectRecord>) => void;
updateOneRecord: ({
idToUpdate,
@@ -3,6 +3,8 @@ import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/rec
import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap';
import { isRecordBoardCardSelectedFamilyStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap';
import { isRecordBoardCompactModeActiveStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap';
import { isRecordBoardFetchingRecordsStateScopeMap } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap';
import { onRecordBoardFetchMoreVisibilityChangeStateScopeMap } from '@/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap';
import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap';
import { recordBoardFieldDefinitionsStateScopeMap } from '@/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap';
import { recordBoardFiltersStateScopeMap } from '@/object-record/record-board/states/recordBoardFiltersStateScopeMap';
@@ -30,6 +32,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => {
recordBoardObjectSingularNameStateScopeMap,
scopeId,
),
getIsFetchingRecordState: getState(
isRecordBoardFetchingRecordsStateScopeMap,
scopeId,
),
getColumnIdsState: getState(recordBoardColumnIdsStateScopeMap, scopeId),
isFirstColumnFamilyState: getFamilyState(
isFirstRecordBoardColumnFamilyStateScopeMap,
@@ -72,5 +78,10 @@ export const useRecordBoardStates = (recordBoardId?: string) => {
isRecordBoardCompactModeActiveStateScopeMap,
scopeId,
),
getOnFetchMoreVisibilityChangeState: getState(
onRecordBoardFetchMoreVisibilityChangeStateScopeMap,
scopeId,
),
};
};
@@ -10,6 +10,8 @@ export const useRecordBoard = (recordBoardId?: string) => {
getFieldDefinitionsState,
getObjectSingularNameState,
getSelectedRecordIdsSelector,
getIsCompactModeActiveState,
getOnFetchMoreVisibilityChangeState,
} = useRecordBoardStates(recordBoardId);
const { setColumns } = useSetRecordBoardColumns(recordBoardId);
@@ -24,5 +26,7 @@ export const useRecordBoard = (recordBoardId?: string) => {
setFieldDefinitions,
setObjectSingularName,
getSelectedRecordIdsSelector,
getIsCompactModeActiveState,
getOnFetchMoreVisibilityChangeState,
};
};
@@ -2,7 +2,7 @@ import { useRecoilCallback } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
export const useResetBoardRecordSelection = (recordBoardId?: string) => {
export const useRecordBoardSelection = (recordBoardId?: string) => {
const { getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } =
useRecordBoardStates(recordBoardId);
@@ -20,5 +20,21 @@ export const useResetBoardRecordSelection = (recordBoardId?: string) => {
[getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState],
);
return { resetRecordSelection };
const setRecordAsSelected = useRecoilCallback(
({ snapshot, set }) =>
(recordId: string, isSelected: boolean) => {
const isRecordCurrentlySelected = snapshot
.getLoadable(isRecordBoardCardSelectedFamilyState(recordId))
.getValue();
if (isRecordCurrentlySelected === isSelected) {
return;
}
set(isRecordBoardCardSelectedFamilyState(recordId), isSelected);
},
[isRecordBoardCardSelectedFamilyState],
);
return { resetRecordSelection, setRecordAsSelected };
};
@@ -1,4 +1,5 @@
import { ReactNode, useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
@@ -20,6 +21,7 @@ import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox';
import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState';
import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState';
import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut';
import { ScrollWrapperContext } from '@/ui/utilities/scroll/components/ScrollWrapper';
const StyledBoardCard = styled.div<{ selected: boolean }>`
background-color: ${({ theme, selected }) =>
@@ -119,22 +121,26 @@ const StyledCompactIconContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
margin-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledRecordInlineCellPlaceholder = styled.div`
height: 24px;
`;
export const RecordBoardCard = () => {
const { recordId } = useContext(RecordBoardCardContext);
const { updateOneRecord } = useContext(RecordBoardContext);
const { updateOneRecord, objectMetadataItem } =
useContext(RecordBoardContext);
const {
getObjectSingularNameState,
getIsCompactModeActiveState,
isRecordBoardCardSelectedFamilyState,
getVisibleFieldDefinitionsState,
} = useRecordBoardStates();
const isCompactModeActive = useRecoilValue(getIsCompactModeActiveState());
const objectNameSingular = useRecoilValue(getObjectSingularNameState());
const [isCardInCompactMode, setIsCardInCompactMode] =
useState(isCompactModeActive);
const [isCardInCompactMode, setIsCardInCompactMode] = useState(true);
const [isCurrentCardSelected, setIsCurrentCardSelected] = useRecoilState(
isRecordBoardCardSelectedFamilyState(recordId),
@@ -190,13 +196,21 @@ export const RecordBoardCard = () => {
return [updateEntity, { loading: false }];
};
if (!objectNameSingular || !record) {
const scrollWrapperRef = useContext(ScrollWrapperContext);
const { ref: cardRef, inView } = useInView({
root: scrollWrapperRef.current,
rootMargin: '1000px',
});
if (!record) {
return null;
}
return (
<StyledBoardCardWrapper onContextMenu={handleContextMenu}>
<StyledBoardCard
ref={cardRef}
selected={isCurrentCardSelected}
onMouseLeave={onMouseLeaveBoard}
onClick={() => {
@@ -204,7 +218,10 @@ export const RecordBoardCard = () => {
}}
>
<StyledBoardCardHeader showCompactView={isCompactModeActive}>
<RecordChip objectNameSingular={objectNameSingular} record={record} />
<RecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={record}
/>
{isCompactModeActive && (
<StyledCompactIconContainer className="compact-icon-container">
<LightIconButton
@@ -226,7 +243,10 @@ export const RecordBoardCard = () => {
</StyledCheckboxContainer>
</StyledBoardCardHeader>
<StyledBoardCardBody>
<AnimatedEaseInOut isOpen={!isCardInCompactMode} initial={false}>
<AnimatedEaseInOut
isOpen={!isCardInCompactMode || !isCompactModeActive}
initial={false}
>
{visibleBoardCardFieldDefinitions.map((fieldDefinition) => (
<PreventSelectOnClickContainer
key={fieldDefinition.fieldMetadataId}
@@ -249,7 +269,11 @@ export const RecordBoardCard = () => {
hotkeyScope: InlineCellHotkeyScope.InlineCell,
}}
>
<RecordInlineCell />
{inView ? (
<RecordInlineCell />
) : (
<StyledRecordInlineCellPlaceholder />
)}
</FieldContext.Provider>
</PreventSelectOnClickContainer>
))}
@@ -18,7 +18,7 @@ export const RecordBoardCardDraggableContainer = ({
{...draggableProvided?.dragHandleProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.draggableProps}
className="entity-board-card"
className="record-board-card"
data-selectable-id={recordId}
data-select-disable
>
@@ -60,6 +60,7 @@ export const RecordBoardColumn = ({
columnDefinition: columnDefinition,
isFirstColumn: isFirstColumn,
isLastColumn: isLastColumn,
recordCount: recordIds.length,
}}
>
<Droppable droppableId={recordBoardColumnId}>
@@ -1,12 +1,14 @@
import React from 'react';
import React, { useContext } from 'react';
import styled from '@emotion/styled';
import { DroppableProvided } from '@hello-pangea/dnd';
import { Draggable, DroppableProvided } from '@hello-pangea/dnd';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnCardsMemo } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsMemo';
const StyledPlaceholder = styled.div`
min-height: 1px;
`;
import { RecordBoardColumnFetchMoreLoader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader';
import { RecordBoardColumnNewButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton';
import { RecordBoardColumnNewOpportunityButton } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
const StyledColumnCardsContainer = styled.div`
display: flex;
@@ -14,6 +16,10 @@ const StyledColumnCardsContainer = styled.div`
flex-direction: column;
`;
const StyledNewButtonContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(4)};
`;
type RecordBoardColumnCardsContainerProps = {
recordIds: string[];
droppableProvided: DroppableProvided;
@@ -23,6 +29,9 @@ export const RecordBoardColumnCardsContainer = ({
recordIds,
droppableProvided,
}: RecordBoardColumnCardsContainerProps) => {
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { objectMetadataItem } = useContext(RecordBoardContext);
return (
<StyledColumnCardsContainer
ref={droppableProvided?.innerRef}
@@ -30,7 +39,29 @@ export const RecordBoardColumnCardsContainer = ({
{...droppableProvided?.droppableProps}
>
<RecordBoardColumnCardsMemo recordIds={recordIds} />
<StyledPlaceholder>{droppableProvided?.placeholder}</StyledPlaceholder>
<RecordBoardColumnFetchMoreLoader />
<Draggable
draggableId={`new-${columnDefinition.id}`}
index={recordIds.length}
isDragDisabled={true}
>
{(draggableProvided) => (
<div
ref={draggableProvided?.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.draggableProps}
>
<StyledNewButtonContainer>
{objectMetadataItem.nameSingular ===
CoreObjectNameSingular.Opportunity ? (
<RecordBoardColumnNewOpportunityButton />
) : (
<RecordBoardColumnNewButton />
)}
</StyledNewButtonContainer>
</div>
)}
</Draggable>
</StyledColumnCardsContainer>
);
};
@@ -0,0 +1,36 @@
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates';
import { grayScale } from '@/ui/theme/constants/colors';
const StyledText = styled.div`
align-items: center;
box-shadow: none;
color: ${grayScale.gray40};
display: flex;
height: 32px;
margin-left: ${({ theme }) => theme.spacing(8)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
export const RecordBoardColumnFetchMoreLoader = () => {
const { getIsFetchingRecordState, getOnFetchMoreVisibilityChangeState } =
useRecordBoardStates();
const isFetchingRecords = useRecoilValue(getIsFetchingRecordState());
const onFetchMoreVisibilityChange = useRecoilValue(
getOnFetchMoreVisibilityChangeState(),
);
const { ref } = useInView({
onChange: onFetchMoreVisibilityChange,
});
return (
<div ref={ref}>
{isFetchingRecords && <StyledText>Loading more...</StyledText>}
</div>
);
};
@@ -46,7 +46,9 @@ export const RecordBoardColumnHeader = () => {
const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false);
const [isHeaderHovered, setIsHeaderHovered] = useState(false);
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { columnDefinition, recordCount } = useContext(
RecordBoardColumnContext,
);
const {
setHotkeyScopeAndMemorizePreviousScope,
@@ -66,7 +68,6 @@ export const RecordBoardColumnHeader = () => {
};
const boardColumnTotal = 0;
const cardIds = [];
return (
<>
@@ -81,7 +82,7 @@ export const RecordBoardColumnHeader = () => {
/>
{!!boardColumnTotal && <StyledAmount>${boardColumnTotal}</StyledAmount>}
{!isHeaderHovered && (
<StyledNumChildren>{cardIds.length}</StyledNumChildren>
<StyledNumChildren>{recordCount}</StyledNumChildren>
)}
{isHeaderHovered && (
<StyledHeaderActions>
@@ -0,0 +1,44 @@
import { useContext } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { IconPlus } from '@/ui/display/icon/index';
const StyledButton = styled.button`
align-items: center;
align-self: baseline;
background-color: ${({ theme }) => theme.background.primary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
&:hover {
background-color: ${({ theme }) => theme.background.tertiary};
}
`;
export const RecordBoardColumnNewButton = () => {
const theme = useTheme();
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { createOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const onNewClick = () => {
createOneRecord({
[selectFieldMetadataItem.name]: columnDefinition.value,
});
};
return (
<StyledButton onClick={onNewClick}>
<IconPlus size={theme.icon.size.md} />
New
</StyledButton>
);
};
@@ -0,0 +1,90 @@
import { useCallback, useContext, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { IconPlus } from '@/ui/display/icon';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
const StyledButton = styled.button`
align-items: center;
align-self: baseline;
background-color: ${({ theme }) => theme.background.primary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)};
&:hover {
background-color: ${({ theme }) => theme.background.tertiary};
}
`;
export const RecordBoardColumnNewOpportunityButton = () => {
const [isCreatingCard, setIsCreatingCard] = useState(false);
const theme = useTheme();
const { columnDefinition } = useContext(RecordBoardColumnContext);
const { createOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleEntitySelect = (company?: EntityForSelect) => {
setIsCreatingCard(false);
goBackToPreviousHotkeyScope();
if (!company) {
return;
}
createOneRecord({
name: company.name,
companyId: company.id,
[selectFieldMetadataItem.name]: columnDefinition.value,
});
};
const handleNewClick = useCallback(() => {
setIsCreatingCard(true);
setHotkeyScopeAndMemorizePreviousScope(
RelationPickerHotkeyScope.RelationPicker,
);
}, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]);
const handleCancel = () => {
goBackToPreviousHotkeyScope();
setIsCreatingCard(false);
};
return (
<>
{isCreatingCard ? (
<SingleEntitySelect
disableBackgroundBlur
onCancel={handleCancel}
onEntitySelected={handleEntitySelect}
relationObjectNameSingular={CoreObjectNameSingular.Company}
relationPickerScopeId="relation-picker"
selectedRelationRecordIds={[]}
/>
) : (
<StyledButton onClick={handleNewClick}>
<IconPlus size={theme.icon.size.md} />
New
</StyledButton>
)}
</>
);
};
@@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = {
columnDefinition: RecordBoardColumnDefinition;
isFirstColumn: boolean;
isLastColumn: boolean;
recordCount: number;
};
export const RecordBoardColumnContext =
@@ -0,0 +1,7 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const isRecordBoardFetchingRecordsStateScopeMap =
createStateScopeMap<boolean>({
key: 'isRecordBoardFetchingRecordsStateScopeMap',
defaultValue: false,
});
@@ -0,0 +1,7 @@
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
export const onRecordBoardFetchMoreVisibilityChangeStateScopeMap =
createStateScopeMap<(visbility: boolean) => void>({
key: 'onRecordBoardFetchMoreVisibilityChangeStateScopeMap',
defaultValue: () => {},
});
@@ -0,0 +1,7 @@
export const RATING_VALUES = [
'RATING_1',
'RATING_2',
'RATING_3',
'RATING_4',
'RATING_5',
] as const;
@@ -8,7 +8,7 @@ export const CurrencyFieldDisplay = () => {
return (
<CurrencyDisplay
amount={
fieldValue.amountMicros ? fieldValue.amountMicros / 1000000 : null
fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : null
}
/>
);
@@ -11,14 +11,6 @@ export type RatingFieldInputProps = {
readonly?: boolean;
};
export const RATING_VALUES = [
'RATING_1',
'RATING_2',
'RATING_3',
'RATING_4',
'RATING_5',
] as const;
export const RatingFieldInput = ({
onSubmit,
readonly,
@@ -1,3 +1,5 @@
import { Key } from 'ts-key-enum';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
@@ -41,7 +43,7 @@ export const useRegisterInputEvents = <T>({
);
useScopedHotkeys(
'esc',
[Key.Escape],
() => {
onEscape?.(inputValue);
},
@@ -1,4 +1,4 @@
import { RATING_VALUES } from '@/object-record/record-field/meta-types/input/components/RatingFieldInput';
import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { ThemeColor } from '@/ui/theme/constants/colors';
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { RATING_VALUES } from '../../meta-types/input/components/RatingFieldInput';
import { RATING_VALUES } from '@/object-record/record-field/meta-types/constants/RatingValues';
import { FieldRatingValue } from '../FieldMetadata';
const ratingSchema = z.string().pipe(z.enum(RATING_VALUES));
@@ -4,6 +4,7 @@ import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldIn
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency';
import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { computeEmptyDraftValue } from '@/object-record/record-field/utils/computeEmptyDraftValue';
import { isFieldValueEmpty } from '@/object-record/record-field/utils/isFieldValueEmpty';
@@ -21,18 +22,20 @@ export const computeDraftValueFromFieldValue = <FieldValue>({
// than the intputDraftValue type as string can be typed anywhere
if (isFieldCurrency(fieldDefinition)) {
if (isFieldValueEmpty({ fieldValue, fieldDefinition })) {
if (
isFieldValueEmpty({ fieldValue, fieldDefinition }) ||
!isFieldCurrencyValue(fieldValue)
) {
return computeEmptyDraftValue<FieldValue>({ fieldDefinition });
}
if (isFieldCurrencyValue(fieldValue)) {
return {
amount: fieldValue?.amountMicros
? fieldValue.amountMicros / 1000000
: '',
currenyCode: CurrencyCode.USD,
} as unknown as FieldInputDraftValue<FieldValue>;
}
return {
amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '',
currenyCode: CurrencyCode.USD,
} as unknown as FieldInputDraftValue<FieldValue>;
}
if (isFieldRelation(fieldDefinition)) {
return computeEmptyDraftValue<FieldValue>({ fieldDefinition });
}
return fieldValue as FieldInputDraftValue<FieldValue>;
@@ -8,6 +8,7 @@ import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldE
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue';
import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid';
@@ -24,7 +25,8 @@ export const computeEmptyDraftValue = <FieldValue>({
isFieldText(fieldDefinition) ||
isFieldDateTime(fieldDefinition) ||
isFieldNumber(fieldDefinition) ||
isFieldEmail(fieldDefinition)
isFieldEmail(fieldDefinition) ||
isFieldRelationValue(fieldDefinition)
) {
return '' as FieldInputDraftValue<FieldValue>;
}
@@ -1,4 +1,4 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
@@ -6,6 +6,7 @@ import { RecordBoardActionBar } from '@/object-record/record-board/action-bar/co
import { RecordBoard } from '@/object-record/record-board/components/RecordBoard';
import { RecordBoardContextMenu } from '@/object-record/record-board/context-menu/components/RecordBoardContextMenu';
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type RecordIndexBoardContainerProps = {
recordBoardId: string;
@@ -18,16 +19,27 @@ export const RecordIndexBoardContainer = ({
recordBoardId,
objectNameSingular,
}: RecordIndexBoardContainerProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
});
const selectFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.type === FieldMetadataType.Select,
);
const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular });
const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular });
const { createOneRecord } = useCreateOneRecord({ objectNameSingular });
if (!selectFieldMetadataItem) {
return;
}
return (
<RecordBoardContext.Provider
value={{
objectMetadataItem,
selectFieldMetadataItem,
createOneRecord,
updateOneRecord,
deleteOneRecord,
@@ -1,12 +1,13 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly';
import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar';
import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard';
import { useResetBoardRecordSelection } from '@/object-record/record-board/hooks/useResetBoardRecordSelection';
import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection';
import { useLoadRecordIndexBoard } from '@/object-record/record-index/hooks/useLoadRecordIndexBoard';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata';
type RecordIndexBoardContainerEffectProps = {
@@ -18,12 +19,37 @@ type RecordIndexBoardContainerEffectProps = {
export const RecordIndexBoardContainerEffect = ({
objectNameSingular,
recordBoardId,
viewBarId,
}: RecordIndexBoardContainerEffectProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
const { objectMetadataItem } = useObjectMetadataItemOnly({
objectNameSingular,
});
useLoadRecordIndexBoard(objectNameSingular, recordBoardId);
const {
setColumns,
setObjectSingularName,
getSelectedRecordIdsSelector,
setFieldDefinitions,
getOnFetchMoreVisibilityChangeState,
} = useRecordBoard(recordBoardId);
const { fetchMoreRecords, loading } = useLoadRecordIndexBoard({
objectNameSingular,
recordBoardId,
viewBarId,
});
const setOnFetchMoreVisibilityChange = useSetRecoilState(
getOnFetchMoreVisibilityChangeState(),
);
useEffect(() => {
setOnFetchMoreVisibilityChange(() => () => {
if (!loading) {
fetchMoreRecords?.();
}
});
}, [fetchMoreRecords, loading, setOnFetchMoreVisibilityChange]);
const navigate = useNavigate();
@@ -31,9 +57,7 @@ export const RecordIndexBoardContainerEffect = ({
navigate(`/settings/objects/${objectMetadataItem.namePlural}`);
}, [navigate, objectMetadataItem.namePlural]);
const { setColumns, setObjectSingularName, getSelectedRecordIdsSelector } =
useRecordBoard(recordBoardId);
const { resetRecordSelection } = useResetBoardRecordSelection(recordBoardId);
const { resetRecordSelection } = useRecordBoardSelection(recordBoardId);
useEffect(() => {
setObjectSingularName(objectNameSingular);
@@ -53,6 +77,14 @@ export const RecordIndexBoardContainerEffect = ({
setColumns,
]);
const recordIndexFieldDefinitions = useRecoilValue(
recordIndexFieldDefinitionsState,
);
useEffect(() => {
setFieldDefinitions(recordIndexFieldDefinitions);
}, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]);
const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector());
const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({
@@ -1,8 +1,7 @@
import { useState } from 'react';
import styled from '@emotion/styled';
import { useSetRecoilState } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useSpreadsheetCompanyImport } from '@/companies/hooks/useSpreadsheetCompanyImport';
import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
@@ -11,19 +10,20 @@ import { RecordIndexBoardContainerEffect } from '@/object-record/record-index/co
import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer';
import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown';
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { TableOptionsDropdown } from '@/object-record/record-table/options/components/TableOptionsDropdown';
import { useSpreadsheetPersonImport } from '@/people/hooks/useSpreadsheetPersonImport';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewField } from '@/views/types/ViewField';
import { ViewType } from '@/views/types/ViewType';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
const StyledContainer = styled.div`
display: flex;
@@ -60,26 +60,38 @@ export const RecordIndexContainer = ({
const { columnDefinitions } =
useColumnDefinitionsFromFieldMetadata(objectMetadataItem);
const setRecordIndexFieldDefinitions = useSetRecoilState(
recordIndexFieldDefinitionsState,
);
const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState);
const setRecordIndexSorts = useSetRecoilState(recordIndexSortsState);
const { openPersonSpreadsheetImport } = useSpreadsheetPersonImport();
const { openCompanySpreadsheetImport } = useSpreadsheetCompanyImport();
const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({
recordTableId: recordIndexId,
});
const handleImport = () => {
const openImport =
objectNamePlural === 'companies'
? openCompanySpreadsheetImport
: openPersonSpreadsheetImport;
openImport();
};
const onViewFieldsChange = useRecoilCallback(
({ set, snapshot }) =>
(viewFields: ViewField[]) => {
setTableColumns(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
const existingRecordIndexFieldDefinitions = snapshot
.getLoadable(recordIndexFieldDefinitionsState)
.getValue();
const newFieldDefinitions = mapViewFieldsToColumnDefinitions(
viewFields,
columnDefinitions,
);
if (
!isDeeplyEqual(
existingRecordIndexFieldDefinitions,
newFieldDefinitions,
)
)
set(recordIndexFieldDefinitionsState, newFieldDefinitions);
},
[columnDefinitions, setTableColumns],
);
return (
<StyledContainer>
@@ -87,24 +99,14 @@ export const RecordIndexContainer = ({
<ViewBar
viewBarId={recordIndexId}
optionsDropdownButton={
<TableOptionsDropdown
recordTableId={recordIndexId}
onImport={
['companies', 'people'].includes(recordIndexId)
? handleImport
: undefined
}
<RecordIndexOptionsDropdown
recordIndexId={recordIndexId}
objectNameSingular={objectNameSingular}
viewType={recordIndexViewType ?? ViewType.Table}
/>
}
optionsDropdownScopeId={TableOptionsDropdownId}
onViewFieldsChange={(viewFields) => {
setTableColumns(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
setRecordIndexFieldDefinitions(
mapViewFieldsToColumnDefinitions(viewFields, columnDefinitions),
);
}}
optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID}
onViewFieldsChange={onViewFieldsChange}
onViewFiltersChange={(viewFilters) => {
setTableFilters(mapViewFiltersToFilters(viewFilters));
setRecordIndexFilters(mapViewFiltersToFilters(viewFilters));
@@ -10,11 +10,19 @@ import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/s
import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState';
import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState';
import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore';
import { useViewBar } from '@/views/hooks/useViewBar';
export const useLoadRecordIndexBoard = (
objectNameSingular: string,
recordBoardId: string,
) => {
type UseLoadRecordIndexBoardProps = {
objectNameSingular: string;
viewBarId: string;
recordBoardId: string;
};
export const useLoadRecordIndexBoard = ({
objectNameSingular,
viewBarId,
recordBoardId,
}: UseLoadRecordIndexBoardProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
@@ -47,6 +55,10 @@ export const useLoadRecordIndexBoard = (
orderBy,
});
const { setEntityCountInCurrentView } = useViewBar({
viewBarId,
});
useEffect(() => {
setRecordIdsInBoard(records);
}, [records, setRecordIdsInBoard]);
@@ -55,6 +67,10 @@ export const useLoadRecordIndexBoard = (
setRecordsInStore(records);
}, [records, setRecordsInStore]);
useEffect(() => {
setEntityCountInCurrentView(records.length);
}, [records.length, setEntityCountInCurrentView]);
return {
records,
loading,
@@ -0,0 +1,38 @@
import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton';
import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent';
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useViewBar } from '@/views/hooks/useViewBar';
import { ViewType } from '@/views/types/ViewType';
type RecordIndexOptionsDropdownProps = {
viewType: ViewType;
objectNameSingular: string;
recordIndexId: string;
};
export const RecordIndexOptionsDropdown = ({
recordIndexId,
objectNameSingular,
viewType,
}: RecordIndexOptionsDropdownProps) => {
const { setViewEditMode } = useViewBar();
return (
<Dropdown
dropdownId={RECORD_INDEX_OPTIONS_DROPDOWN_ID}
clickableComponent={<RecordIndexOptionsDropdownButton />}
dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }}
dropdownOffset={{ y: 8 }}
dropdownComponents={
<RecordIndexOptionsDropdownContent
viewType={viewType}
objectNameSingular={objectNameSingular}
recordIndexId={recordIndexId}
/>
}
onClickOutside={() => setViewEditMode('none')}
/>
);
};
@@ -1,10 +1,10 @@
import { TableOptionsDropdownId } from '@/object-record/record-table/constants/TableOptionsDropdownId';
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
export const TableOptionsDropdownButton = () => {
export const RecordIndexOptionsDropdownButton = () => {
const { isDropdownOpen, toggleDropdown } = useDropdown(
TableOptionsDropdownId,
RECORD_INDEX_OPTIONS_DROPDOWN_ID,
);
return (
@@ -0,0 +1,198 @@
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId';
import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard';
import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable';
import { useRecordIndexOptionsImport } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsImport';
import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope';
import {
IconBaselineDensitySmall,
IconChevronLeft,
IconFileImport,
IconTag,
} from '@/ui/display/icon';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates';
import { useViewBar } from '@/views/hooks/useViewBar';
import { ViewType } from '@/views/types/ViewType';
type RecordIndexOptionsMenu = 'fields';
type RecordIndexOptionsDropdownContentProps = {
recordIndexId: string;
objectNameSingular: string;
viewType: ViewType;
};
export const RecordIndexOptionsDropdownContent = ({
viewType,
recordIndexId,
objectNameSingular,
}: RecordIndexOptionsDropdownContentProps) => {
const { setViewEditMode, handleViewNameSubmit } = useViewBar({
viewBarId: recordIndexId,
});
const { viewEditModeState, currentViewSelector } = useViewScopedStates();
const viewEditMode = useRecoilValue(viewEditModeState);
const currentView = useRecoilValue(currentViewSelector);
const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID);
const [currentMenu, setCurrentMenu] = useState<
RecordIndexOptionsMenu | undefined
>(undefined);
const resetMenu = () => setCurrentMenu(undefined);
const viewEditInputRef = useRef<HTMLInputElement>(null);
const handleSelectMenu = (option: RecordIndexOptionsMenu) => {
setCurrentMenu(option);
};
useScopedHotkeys(
[Key.Escape],
() => {
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
useScopedHotkeys(
Key.Enter,
() => {
const name = viewEditInputRef.current?.value;
handleViewNameSubmit(name);
resetMenu();
setViewEditMode('none');
closeDropdown();
},
TableOptionsHotkeyScope.Dropdown,
);
const {
handleColumnVisibilityChange,
handleReorderColumns,
visibleTableColumns,
hiddenTableColumns,
} = useRecordIndexOptionsForTable(recordIndexId);
const {
visibleBoardFields,
hiddenBoardFields,
handleReorderBoardFields,
handleBoardFieldVisibilityChange,
isCompactModeActive,
setIsCompactModeActive,
} = useRecordIndexOptionsForBoard({
objectNameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const visibleRecordFields =
viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns;
const hiddenRecordFields =
viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns;
const handleReorderFields =
viewType === ViewType.Kanban
? handleReorderBoardFields
: handleReorderColumns;
const handleChangeFieldVisibility =
viewType === ViewType.Kanban
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const { handleImport } = useRecordIndexOptionsImport({ objectNameSingular });
return (
<>
{!currentMenu && (
<>
<DropdownMenuInput
ref={viewEditInputRef}
autoFocus={viewEditMode !== 'none'}
placeholder={
viewEditMode === 'create'
? 'New view'
: viewEditMode === 'edit'
? 'View name'
: ''
}
defaultValue={viewEditMode === 'create' ? '' : currentView?.name}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItem
onClick={() => handleSelectMenu('fields')}
LeftIcon={IconTag}
text="Fields"
/>
{handleImport && (
<MenuItem
onClick={() => handleImport()}
LeftIcon={IconFileImport}
text="Import"
/>
)}
</DropdownMenuItemsContainer>
</>
)}
{currentMenu === 'fields' && (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={resetMenu}>
Fields
</DropdownMenuHeader>
<DropdownMenuSeparator />
<ViewFieldsVisibilityDropdownSection
title="Visible"
fields={visibleRecordFields}
isVisible={true}
onVisibilityChange={handleChangeFieldVisibility}
isDraggable={true}
onDragEnd={handleReorderFields}
/>
{hiddenRecordFields.length > 0 && (
<>
<DropdownMenuSeparator />
<ViewFieldsVisibilityDropdownSection
title="Hidden"
fields={hiddenRecordFields}
isVisible={false}
onVisibilityChange={handleChangeFieldVisibility}
isDraggable={false}
/>
</>
)}
</>
)}
{viewType === ViewType.Kanban && (
<>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconBaselineDensitySmall}
onToggleChange={setIsCompactModeActive}
toggled={isCompactModeActive}
text="Compact view"
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</>
)}
</>
);
};

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