Merge branch 'main' into 3628-timebox-separate-user-creation-from-workspace-creation
This commit is contained in:
@@ -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
@@ -17,4 +17,6 @@
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
coverage
|
||||
.vercel
|
||||
.vercel
|
||||
|
||||
**/**/logs/**
|
||||
+2
-2
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-emails",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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
-3
@@ -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;
|
||||
|
||||
+18
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-2
@@ -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
-2
@@ -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;
|
||||
|
||||
+7
-1
@@ -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}
|
||||
/>
|
||||
|
||||
+24
-35
@@ -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>
|
||||
|
||||
+22
@@ -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 {
|
||||
|
||||
+27
-46
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+58
@@ -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 },
|
||||
});
|
||||
+12
@@ -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 },
|
||||
});
|
||||
+6
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+53
@@ -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>
|
||||
);
|
||||
};
|
||||
-76
@@ -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,
|
||||
},
|
||||
];
|
||||
}, []),
|
||||
);
|
||||
+10
-1
@@ -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);
|
||||
+28
@@ -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;
|
||||
};
|
||||
+59
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
+16
-2
@@ -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,
|
||||
)
|
||||
|
||||
+18
-4
@@ -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) });
|
||||
});
|
||||
};
|
||||
|
||||
+49
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
+27
-7
@@ -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,
|
||||
|
||||
-86
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
+112
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
+5
@@ -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}
|
||||
|
||||
+7
-5
@@ -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">
|
||||
|
||||
+70
@@ -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');
|
||||
});
|
||||
});
|
||||
-55
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
-1
@@ -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,
|
||||
|
||||
Vendored
-41
@@ -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,
|
||||
};
|
||||
};
|
||||
+72
@@ -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,
|
||||
};
|
||||
};
|
||||
+1
-1
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+16
-6
@@ -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: [],
|
||||
|
||||
+20
-16
@@ -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();
|
||||
|
||||
+16
-7
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+7
-7
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
-8
@@ -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 />
|
||||
|
||||
+3
-1
@@ -69,7 +69,9 @@ export const RecordBoardDeprecatedColumnDropdownMenu = ({
|
||||
|
||||
useScopedHotkeys(
|
||||
[Key.Escape, Key.Enter],
|
||||
closeMenu,
|
||||
() => {
|
||||
closeMenu();
|
||||
},
|
||||
BoardColumnHotkeyScope.BoardColumn,
|
||||
[],
|
||||
);
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export const RecordBoardDeprecatedOptionsDropdownContent = ({
|
||||
);
|
||||
|
||||
useScopedHotkeys(
|
||||
Key.Escape,
|
||||
[Key.Escape],
|
||||
() => {
|
||||
setViewEditMode('none');
|
||||
closeDropdown();
|
||||
|
||||
+19
-6
@@ -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>
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+11
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+18
-2
@@ -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 };
|
||||
};
|
||||
+33
-9
@@ -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>
|
||||
))}
|
||||
|
||||
+1
-1
@@ -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
|
||||
>
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export const RecordBoardColumn = ({
|
||||
columnDefinition: columnDefinition,
|
||||
isFirstColumn: isFirstColumn,
|
||||
isLastColumn: isLastColumn,
|
||||
recordCount: recordIds.length,
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId={recordBoardColumnId}>
|
||||
|
||||
+38
-7
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+36
@@ -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>
|
||||
);
|
||||
};
|
||||
+4
-3
@@ -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>
|
||||
|
||||
+44
@@ -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>
|
||||
);
|
||||
};
|
||||
+90
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
@@ -6,6 +6,7 @@ type RecordBoardColumnContextProps = {
|
||||
columnDefinition: RecordBoardColumnDefinition;
|
||||
isFirstColumn: boolean;
|
||||
isLastColumn: boolean;
|
||||
recordCount: number;
|
||||
};
|
||||
|
||||
export const RecordBoardColumnContext =
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
|
||||
|
||||
export const isRecordBoardFetchingRecordsStateScopeMap =
|
||||
createStateScopeMap<boolean>({
|
||||
key: 'isRecordBoardFetchingRecordsStateScopeMap',
|
||||
defaultValue: false,
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap';
|
||||
|
||||
export const onRecordBoardFetchMoreVisibilityChangeStateScopeMap =
|
||||
createStateScopeMap<(visbility: boolean) => void>({
|
||||
key: 'onRecordBoardFetchMoreVisibilityChangeStateScopeMap',
|
||||
defaultValue: () => {},
|
||||
});
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export const RATING_VALUES = [
|
||||
'RATING_1',
|
||||
'RATING_2',
|
||||
'RATING_3',
|
||||
'RATING_4',
|
||||
'RATING_5',
|
||||
] as const;
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const CurrencyFieldDisplay = () => {
|
||||
return (
|
||||
<CurrencyDisplay
|
||||
amount={
|
||||
fieldValue.amountMicros ? fieldValue.amountMicros / 1000000 : null
|
||||
fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
-8
@@ -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,
|
||||
|
||||
+3
-1
@@ -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';
|
||||
|
||||
|
||||
+2
-1
@@ -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));
|
||||
|
||||
+12
-9
@@ -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>;
|
||||
|
||||
+3
-1
@@ -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>;
|
||||
}
|
||||
|
||||
+14
-2
@@ -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,
|
||||
|
||||
+40
-8
@@ -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({
|
||||
|
||||
+36
-34
@@ -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));
|
||||
|
||||
+20
-4
@@ -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,
|
||||
|
||||
+38
@@ -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')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -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 (
|
||||
+198
@@ -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
Reference in New Issue
Block a user