Improve lazy loading (#12393)

Creating manual chunk was a bad idea, we should always solve lazy
loading problem at the source instance.

Setting a 4.5MB for the index bundle size, CI will fail if we go above.

There is still a lot of room for optimizations!
- More agressive lazy loading (e.g. xyflow and tiptap are still loaded
in index!)
- Add a  prefetch mechanism
- Add stronger CI checks to make sure libraries we've set asides are not
added back
- Fix AllIcons component with does not work as intended (loaded on
initial load)
This commit is contained in:
Félix Malfait
2025-06-01 09:33:16 +02:00
committed by GitHub
parent c74d7fe986
commit f6bfec882a
37 changed files with 577 additions and 277 deletions

View File

@ -1,7 +1,6 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { BlockNoteEditor } from '@blocknote/core';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@ -33,15 +32,11 @@ export const ExportNoteActionSingleRecordAction = () => {
console.warn(initialBody);
}
const editor = BlockNoteEditor.create({
initialContent: parsedBody,
});
const { exportBlockNoteEditorToPdf } = await import(
'@/action-menu/actions/record-actions/single-record/utils/exportBlockNoteEditorToPdf'
);
await exportBlockNoteEditorToPdf(editor, filename);
await exportBlockNoteEditorToPdf(parsedBody, filename);
// TODO later: implement DOCX export
// const { exportBlockNoteEditorToDocx } = await import(

View File

@ -1,4 +1,4 @@
import { BlockNoteEditor } from '@blocknote/core';
import { BlockNoteEditor, PartialBlock } from '@blocknote/core';
import {
PDFExporter,
pdfDefaultSchemaMappings,
@ -7,9 +7,13 @@ import { pdf } from '@react-pdf/renderer';
import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToPdf = async (
editor: BlockNoteEditor,
parsedBody: PartialBlock[],
filename: string,
) => {
const editor = BlockNoteEditor.create({
initialContent: parsedBody,
});
const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings);
const pdfDocument = await exporter.toReactPDFDocument(editor.document);

View File

@ -34,7 +34,7 @@ import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRec
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';

View File

@ -2,7 +2,7 @@ import { Attachment } from '@/activities/files/types/Attachment';
export const getActivityAttachmentIdsToDelete = (
newActivityBody: string,
oldActivityAttachments: Attachment[],
oldActivityAttachments: Attachment[] = [],
) => {
if (oldActivityAttachments.length === 0) return [];

View File

@ -18,7 +18,6 @@ import { AuthTokenPair } from '~/generated/graphql';
import { logDebug } from '~/utils/logDebug';
import { i18n } from '@lingui/core';
import { captureException } from '@sentry/react';
import { GraphQLFormattedError } from 'graphql';
import { isDefined } from 'twenty-shared/utils';
import { cookieStorage } from '~/utils/cookie-storage';
@ -160,7 +159,17 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
}, Path: ${graphQLError.path}`,
);
}
import('@sentry/react')
.then(({ captureException }) => {
captureException(graphQLError);
})
.catch((sentryError) => {
// eslint-disable-next-line no-console
console.error(
'Failed to capture GraphQL error with Sentry:',
sentryError,
);
});
}
}
}

View File

@ -1,9 +1,18 @@
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilValue } from 'recoil';
import { viewableRichTextComponentState } from '../states/viewableRichTextComponentState';
const ActivityRichTextEditor = lazy(() =>
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
default: module.ActivityRichTextEditor,
})),
);
const StyledContainer = styled.div`
box-sizing: border-box;
margin: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(-2)};
@ -11,6 +20,20 @@ const StyledContainer = styled.div`
width: 100%;
`;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.s} />
</SkeletonTheme>
);
};
export const CommandMenuEditRichTextPage = () => {
const { activityId, activityObjectNameSingular } = useRecoilValue(
viewableRichTextComponentState,
@ -27,10 +50,12 @@ export const CommandMenuEditRichTextPage = () => {
return (
<StyledContainer>
<Suspense fallback={<LoadingSkeleton />}>
<ActivityRichTextEditor
activityId={activityId}
activityObjectNameSingular={activityObjectNameSingular}
/>
</Suspense>
</StyledContainer>
);
};

View File

@ -1,5 +1,4 @@
import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect';
import { captureException } from '@sentry/react';
import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
@ -14,11 +13,17 @@ export const AppErrorBoundary = ({
FallbackComponent,
resetOnLocationChange = true,
}: AppErrorBoundaryProps) => {
const handleError = (error: Error, info: ErrorInfo) => {
const handleError = async (error: Error, info: ErrorInfo) => {
try {
const { captureException } = await import('@sentry/react');
captureException(error, (scope) => {
scope.setExtras({ info });
return scope;
});
} catch (sentryError) {
// eslint-disable-next-line no-console
console.error('Failed to capture exception with Sentry:', sentryError);
}
};
const handleReset = () => {

View File

@ -1,9 +1,3 @@
import {
browserTracingIntegration,
init,
replayIntegration,
setUser,
} from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
@ -27,28 +21,50 @@ export const SentryInitEffect = () => {
const [isSentryUserDefined, setIsSentryUserDefined] = useState(false);
useEffect(() => {
const initializeSentry = async () => {
if (
!isSentryInitializing &&
isNonEmptyString(sentryConfig?.dsn) &&
!isSentryInitialized
!isSentryInitialized &&
!isSentryInitializing
) {
setIsSentryInitializing(true);
try {
const { init, browserTracingIntegration, replayIntegration } =
await import('@sentry/react');
init({
environment: sentryConfig?.environment ?? undefined,
release: sentryConfig?.release ?? undefined,
dsn: sentryConfig?.dsn,
integrations: [browserTracingIntegration({}), replayIntegration()],
tracePropagationTargets: ['localhost:3001', REACT_APP_SERVER_BASE_URL],
tracePropagationTargets: [
'localhost:3001',
REACT_APP_SERVER_BASE_URL,
],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
setIsSentryInitialized(true);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to initialize Sentry:', error);
} finally {
setIsSentryInitializing(false);
}
}
};
if (isDefined(currentUser) && !isSentryUserDefined) {
const updateSentryUser = async () => {
if (
isSentryInitialized &&
isDefined(currentUser) &&
!isSentryUserDefined
) {
try {
const { setUser } = await import('@sentry/react');
setUser({
email: currentUser?.email,
id: currentUser?.id,
@ -56,9 +72,23 @@ export const SentryInitEffect = () => {
workspaceMemberId: currentWorkspaceMember?.id,
});
setIsSentryUserDefined(true);
} else {
setUser(null);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to set Sentry user:', error);
}
} else if (!isDefined(currentUser) && isSentryInitialized) {
try {
const { setUser } = await import('@sentry/react');
setUser(null);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to clear Sentry user:', error);
}
}
};
initializeSentry();
updateSentryUser();
}, [
sentryConfig,
isSentryInitialized,
@ -68,5 +98,6 @@ export const SentryInitEffect = () => {
currentWorkspaceMember,
isSentryUserDefined,
]);
return <></>;
};

View File

@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { useObjectNamePluralFromSingular } from '../useObjectNamePluralFromSingular';
describe('useObjectNamePluralFromSingular', () => {
@ -8,7 +9,13 @@ describe('useObjectNamePluralFromSingular', () => {
const { result } = renderHook(
() => useObjectNamePluralFromSingular({ objectNameSingular: 'person' }),
{
wrapper: RecoilRoot,
wrapper: ({ children }) => (
<RecoilRoot>
<JestObjectMetadataItemSetter>
{children}
</JestObjectMetadataItemSetter>
</RecoilRoot>
),
},
);

View File

@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { useObjectNameSingularFromPlural } from '../useObjectNameSingularFromPlural';
describe('useObjectNameSingularFromPlural', () => {
@ -8,7 +9,13 @@ describe('useObjectNameSingularFromPlural', () => {
const { result } = renderHook(
() => useObjectNameSingularFromPlural({ objectNamePlural: 'people' }),
{
wrapper: RecoilRoot,
wrapper: ({ children }) => (
<RecoilRoot>
<JestObjectMetadataItemSetter>
{children}
</JestObjectMetadataItemSetter>
</RecoilRoot>
),
},
);

View File

@ -1,13 +1,16 @@
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useRecoilCallback } from 'recoil';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useLoadMockedObjectMetadataItems = () => {
const loadMockedObjectMetadataItems = useRecoilCallback(
({ set, snapshot }) =>
() => {
async () => {
const { generatedMockObjectMetadataItems } = await import(
'~/testing/mock-data/generatedMockObjectMetadataItems'
);
if (
!isDeeplyEqual(
snapshot.getLoadable(objectMetadataItemsState).getValue(),

View File

@ -1,33 +1,20 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const useObjectNamePluralFromSingular = ({
objectNameSingular,
}: {
objectNameSingular: string;
}) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
let objectMetadataItem = useRecoilValue(
const objectMetadataItem = useRecoilValue(
objectMetadataItemFamilySelector({
objectName: objectNameSingular,
objectNameType: 'singular',
}),
);
if (!isWorkspaceActiveOrSuspended(currentWorkspace)) {
objectMetadataItem =
generatedMockObjectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectNameSingular,
) ?? null;
}
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for ${objectNameSingular} object`,

View File

@ -1,33 +1,20 @@
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const useObjectNameSingularFromPlural = ({
objectNamePlural,
}: {
objectNamePlural: string;
}) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
let objectMetadataItem = useRecoilValue(
const objectMetadataItem = useRecoilValue(
objectMetadataItemFamilySelector({
objectName: objectNamePlural,
objectNameType: 'plural',
}),
);
if (!isWorkspaceActiveOrSuspended(currentWorkspace)) {
objectMetadataItem =
generatedMockObjectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.namePlural === objectNamePlural,
) ?? null;
}
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for ${objectNamePlural} object`,

View File

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { CountryCode } from 'libphonenumber-js';
import type { CountryCode } from 'libphonenumber-js';
import { IconCircleOff, IconComponentProps } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';

View File

@ -1,6 +1,6 @@
import { useRichTextV2FieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRichTextV2FieldDisplay';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { isDefined, parseJson } from 'twenty-shared/utils';
export const RichTextV2FieldDisplay = () => {

View File

@ -9,7 +9,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue';
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';

View File

@ -5,7 +5,7 @@ import { useRecordFieldValue } from '@/object-record/record-store/contexts/Recor
import { FieldRichTextValue } from '@/object-record/record-field/types/FieldMetadata';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { isDefined, parseJson } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';

View File

@ -12,7 +12,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isFieldRichTextV2Value } from '@/object-record/record-field/types/guards/isFieldRichTextValueV2';
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';

View File

@ -1,4 +1,4 @@
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useRichTextCommandMenu } from '@/command-menu/hooks/useRichTextCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -8,11 +8,19 @@ import {
FieldInputEvent,
} from '@/object-record/record-field/types/FieldInputEvent';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRef } from 'react';
import { lazy, Suspense, useRef } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { IconLayoutSidebarLeftCollapse } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
const ActivityRichTextEditor = lazy(() =>
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
default: module.ActivityRichTextEditor,
})),
);
export type RichTextFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent;
onCancel?: () => void;
@ -38,6 +46,19 @@ const StyledCollapseButton = styled.div`
display: flex;
`;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.s} />
</SkeletonTheme>
);
};
export const RichTextFieldInput = ({
targetableObject,
onClickOutside,
@ -70,10 +91,12 @@ export const RichTextFieldInput = ({
return (
<StyledContainer ref={containerRef}>
<Suspense fallback={<LoadingSkeleton />}>
<ActivityRichTextEditor
activityId={targetableObject.id}
activityObjectNameSingular={targetableObject.targetObjectNameSingular}
/>
</Suspense>
<StyledCollapseButton>
<FloatingIconButton
Icon={IconLayoutSidebarLeftCollapse}

View File

@ -1,4 +1,5 @@
import { Calendar } from '@/activities/calendar/components/Calendar';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { EmailThreads } from '@/activities/emails/components/EmailThreads';
import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes';
@ -10,16 +11,15 @@ import { CardType } from '@/object-record/record-show/types/CardType';
import { ListenRecordUpdatesEffect } from '@/subscription/components/ListenUpdatesEffect';
import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowRunVisualizer } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizer';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect';
import { WorkflowVersionVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
import { WorkflowRunVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowRunVisualizerComponentInstanceContext';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useId } from 'react';
import { lazy, Suspense, useId } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
background: ${({ theme, isInRightDrawer }) =>
@ -34,6 +34,15 @@ const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
isInRightDrawer ? theme.spacing(4) : ''};
`;
const StyledLoadingSkeletonContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
width: 100%;
`;
type CardComponentProps = {
targetableObject: Pick<
ActivityTargetableObject,
@ -44,6 +53,48 @@ type CardComponentProps = {
type CardComponentType = (props: CardComponentProps) => JSX.Element | null;
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<StyledLoadingSkeletonContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.m} />
</SkeletonTheme>
</StyledLoadingSkeletonContainer>
);
};
const WorkflowVisualizer = lazy(() =>
import('@/workflow/workflow-diagram/components/WorkflowVisualizer').then(
(module) => ({
default: module.WorkflowVisualizer,
}),
),
);
const WorkflowVersionVisualizer = lazy(() =>
import(
'@/workflow/workflow-diagram/components/WorkflowVersionVisualizer'
).then((module) => ({
default: module.WorkflowVersionVisualizer,
})),
);
const WorkflowRunVisualizer = lazy(() =>
import('@/workflow/workflow-diagram/components/WorkflowRunVisualizer').then(
(module) => ({
default: module.WorkflowRunVisualizer,
}),
),
);
export const CardComponents: Record<CardType, CardComponentType> = {
[CardType.TimelineCard]: ({ targetableObject, isInRightDrawer }) => (
<TimelineActivities
@ -95,7 +146,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
}}
>
<WorkflowVisualizerEffect workflowId={targetableObject.id} />
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowVisualizer workflowId={targetableObject.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
@ -112,7 +165,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
<WorkflowVersionVisualizerEffect
workflowVersionId={targetableObject.id}
/>
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowVersionVisualizer workflowVersionId={targetableObject.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider>
);
},
@ -139,7 +194,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
recordId={targetableObject.id}
listenedFields={['status', 'output']}
/>
<Suspense fallback={<LoadingSkeleton />}>
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
</Suspense>
</WorkflowRunVisualizerComponentInstanceContext.Provider>
</WorkflowVisualizerComponentInstanceContext.Provider>
);

View File

@ -1,23 +1,23 @@
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
describe('getFunctionInputFromSourceCode', () => {
it('should return empty input if not parameter', () => {
it('should return empty input if not parameter', async () => {
const fileContent = 'function testFunction() { return }';
const result = getFunctionInputFromSourceCode(fileContent);
const result = await getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return first input if multiple parameters', () => {
it('should return first input if multiple parameters', async () => {
const fileContent =
'function testFunction(params1: {}, params2: {}) { return }';
const result = getFunctionInputFromSourceCode(fileContent);
const result = await getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return empty input if wrong parameter', () => {
it('should return empty input if wrong parameter', async () => {
const fileContent = 'function testFunction(params: string) { return }';
const result = getFunctionInputFromSourceCode(fileContent);
const result = await getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({});
});
it('should return input from source code', () => {
it('should return input from source code', async () => {
const fileContent = `
function testFunction(
params: {
@ -34,7 +34,7 @@ describe('getFunctionInputFromSourceCode', () => {
}
`;
const result = getFunctionInputFromSourceCode(fileContent);
const result = await getFunctionInputFromSourceCode(fileContent);
expect(result).toEqual({
param1: null,
param2: null,

View File

@ -1,16 +1,18 @@
import { getDefaultFunctionInputFromInputSchema } from '@/serverless-functions/utils/getDefaultFunctionInputFromInputSchema';
import { getFunctionInputSchema } from '@/serverless-functions/utils/getFunctionInputSchema';
import { FunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/types/FunctionInput';
import { isObject } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const getFunctionInputFromSourceCode = (
export const getFunctionInputFromSourceCode = async (
sourceCode?: string,
): FunctionInput => {
): Promise<FunctionInput> => {
if (!isDefined(sourceCode)) {
throw new Error('Source code is not defined');
}
const { getFunctionInputSchema } = await import(
'@/serverless-functions/utils/getFunctionInputSchema'
);
const functionInputSchema = getFunctionInputSchema(sourceCode);
const result = getDefaultFunctionInputFromInputSchema(functionInputSchema)[0];

View File

@ -6,7 +6,6 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { ResponsiveLine } from '@nivo/line';
import { isNumber } from '@tiptap/core';
import {
QueueMetricsTimeRange,
useGetQueueMetricsQuery,
@ -204,7 +203,8 @@ export const WorkerMetricsGraph = ({
.filter(([key]) => key !== '__typename')
.map(([key, value]) => ({
label: key.charAt(0).toUpperCase() + key.slice(1),
value: isNumber(value)
value:
typeof value === 'number'
? value
: Array.isArray(value)
? value.length

View File

@ -7,7 +7,7 @@ import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/u
import { Select } from '@/ui/input/components/Select';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { useLingui } from '@lingui/react/macro';
import { CountryCode } from 'libphonenumber-js';
import type { CountryCode } from 'libphonenumber-js';
import { IconCircleOff, IconComponentProps, IconMap } from 'twenty-ui/display';
import { z } from 'zod';
import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToString';

View File

@ -1,4 +1,3 @@
import dagre from '@dagrejs/dagre';
import { useTheme } from '@emotion/react';
import { Edge, Node } from '@xyflow/react';
import { useEffect } from 'react';
@ -21,12 +20,15 @@ export const SettingsDataModelOverviewEffect = ({
useFilteredObjectMetadataItems();
useEffect(() => {
const g = new dagre.graphlib.Graph();
const loadDagreAndLayout = async () => {
const dagre = await import('@dagrejs/dagre');
const g = new dagre.default.graphlib.Graph();
g.setGraph({ rankdir: 'LR' });
g.setDefaultEdgeLabel(() => ({}));
const edges: Edge[] = [];
const nodes = [];
const nodes: Node[] = [];
let i = 0;
for (const object of items) {
nodes.push({
@ -44,7 +46,8 @@ export const SettingsDataModelOverviewEffect = ({
isDefined(field.relationDefinition) &&
isDefined(
items.find(
(x) => x.id === field.relationDefinition?.targetObjectMetadata.id,
(x) =>
x.id === field.relationDefinition?.targetObjectMetadata.id,
),
)
) {
@ -74,7 +77,10 @@ export const SettingsDataModelOverviewEffect = ({
targetObject: targetObj,
},
});
if (!isUndefinedOrNull(sourceObj) && !isUndefinedOrNull(targetObj)) {
if (
!isUndefinedOrNull(sourceObj) &&
!isUndefinedOrNull(targetObj)
) {
g.setEdge(sourceObj, targetObj);
}
}
@ -82,20 +88,23 @@ export const SettingsDataModelOverviewEffect = ({
i++;
}
dagre.layout(g);
dagre.default.layout(g);
nodes.forEach((node) => {
const nodeWithPosition = g.node(node.id);
node.position = {
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
x: nodeWithPosition.x - node.width / 2,
y: nodeWithPosition.y - node.height / 2,
x: nodeWithPosition.x - (node.width ?? 0) / 2,
y: nodeWithPosition.y - (node.height ?? 0) / 2,
};
});
setNodes(nodes);
setEdges(edges);
};
loadDagreAndLayout();
}, [items, setEdges, setNodes, theme]);
return <></>;

View File

@ -1,11 +1,11 @@
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { Dispatch, SetStateAction, useState } from 'react';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { Dispatch, SetStateAction, useState } from 'react';
import { useRecoilState } from 'recoil';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
export type ServerlessFunctionNewFormValues = {
name: string;

View File

@ -3,16 +3,17 @@ import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/N
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SettingsPath } from '@/types/SettingsPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { useRecoilValue } from 'recoil';
import { IconSearch, IconSettings } from 'twenty-ui/display';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledMainSection = styled(NavigationDrawerSection)`
min-height: fit-content;
@ -35,6 +36,7 @@ export const SignInAppNavigationDrawerMock = ({
}: SignInAppNavigationDrawerMockProps) => {
const isMobile = useIsMobile();
const { t } = useLingui();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return (
<NavigationDrawer className={className} title={DEFAULT_WORKSPACE_NAME}>
@ -57,7 +59,7 @@ export const SignInAppNavigationDrawerMock = ({
<NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Workspace`}
isRemote={false}
objectMetadataItems={generatedMockObjectMetadataItems.filter((item) =>
objectMetadataItems={objectMetadataItems.filter((item) =>
WORKSPACE_FAVORITES.includes(item.nameSingular),
)}
/>

View File

@ -1,12 +1,33 @@
import { useTheme } from '@emotion/react';
import React from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { SPREADSHEET_IMPORT_MODAL_ID } from '@/spreadsheet-import/constants/SpreadsheetImportModalId';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { matchColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { SpreadsheetImport } from './SpreadsheetImport';
const SpreadsheetImport = React.lazy(() =>
import('./SpreadsheetImport').then((module) => ({
default: module.SpreadsheetImport,
})),
);
const LoadingSkeleton = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={theme.border.radius.sm}
>
<Skeleton height={SKELETON_LOADER_HEIGHT_SIZES.standard.s} />
</SkeletonTheme>
);
};
type SpreadsheetImportProviderProps = React.PropsWithChildren;
@ -36,11 +57,13 @@ export const SpreadsheetImportProvider = (
<>
{props.children}
{spreadsheetImportDialog.isOpen && spreadsheetImportDialog.options && (
<React.Suspense fallback={<LoadingSkeleton />}>
<SpreadsheetImport
onClose={handleClose}
// eslint-disable-next-line react/jsx-props-no-spreading
{...spreadsheetImportDialog.options}
/>
</React.Suspense>
)}
</>
);

View File

@ -1,9 +1,11 @@
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import ReactDatePicker from 'react-datepicker';
import { lazy, Suspense, useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
@ -13,8 +15,8 @@ import {
VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit,
} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import 'react-datepicker/dist/react-datepicker.css';
import { IconCalendarX } from 'twenty-ui/display';
import {
@ -276,6 +278,20 @@ const StyledButton = styled(MenuItemLeftContent)`
justify-content: start;
`;
const StyledDatePickerFallback = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
height: 300px;
justify-content: center;
padding: ${({ theme }) => theme.spacing(4)};
width: 280px;
`;
type DateTimePickerProps = {
isRelative?: boolean;
hideHeaderInput?: boolean;
@ -306,6 +322,8 @@ type DateTimePickerProps = {
onClear?: () => void;
};
const ReactDatePicker = lazy(() => import('react-datepicker'));
export const DateTimePicker = ({
date,
onChange,
@ -322,6 +340,7 @@ export const DateTimePicker = ({
const internalDate = date ?? new Date();
const { timeZone } = useContext(UserContext);
const theme = useTheme();
const { closeDropdown: closeDropdownMonthSelect } = useDropdown(
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
@ -452,6 +471,34 @@ export const DateTimePicker = ({
return (
<StyledContainer calendarDisabled={isRelative}>
<div className={clearable ? 'clearable ' : ''}>
<Suspense
fallback={
<StyledDatePickerFallback>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
width={200}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={240}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
<Skeleton
width={220}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</SkeletonTheme>
</StyledDatePickerFallback>
}
>
<ReactDatePicker
open={true}
selected={hasDate ? dateToUse : undefined}
@ -497,6 +544,7 @@ export const DateTimePicker = ({
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</Suspense>
</div>
{clearable && (
<StyledButtonContainer onClick={handleClear}>

View File

@ -1,5 +1,5 @@
import { FlagComponent } from 'country-flag-icons/react/3x2';
import { CountryCallingCode, CountryCode } from 'libphonenumber-js';
import type { CountryCallingCode, CountryCode } from 'libphonenumber-js';
export type Country = {
countryCode: CountryCode;

View File

@ -1,4 +1,4 @@
import { SuggestionMenuProps } from '@blocknote/react';
import type { SuggestionMenuProps } from '@blocknote/react';
import styled from '@emotion/styled';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';

View File

@ -1,4 +1,4 @@
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { getFirstNonEmptyLineOfRichText } from '../getFirstNonEmptyLineOfRichText';
describe('getFirstNonEmptyLineOfRichText', () => {

View File

@ -1,4 +1,4 @@
import { PartialBlock } from '@blocknote/core';
import type { PartialBlock } from '@blocknote/core';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getFirstNonEmptyLineOfRichText = (

View File

@ -111,7 +111,6 @@ export const Default: Story = {
const canvas = within(canvasElement);
expect(await canvas.findByText('Send Email')).toBeVisible();
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();

View File

@ -5,8 +5,8 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { mockedUserData } from '~/testing/mock-data/users';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@ -19,15 +19,22 @@ export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
useEffect(() => {
setCurrentWorkspaceMember(mockWorkspaceMembers[0]);
setCurrentUser(mockedUserData);
setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
}, [setCurrentUser, setCurrentWorkspaceMember, setCurrentUserWorkspace]);
loadMockedObjectMetadataItems();
}, [
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentUserWorkspace,
loadMockedObjectMetadataItems,
]);
return (
<>
<ObjectMetadataItemsLoadEffect />
<PreComputedChipGeneratorsProvider>
{!!objectMetadataItems.length && <Story />}
</PreComputedChipGeneratorsProvider>

View File

@ -37,6 +37,14 @@ export default defineConfig(({ command, mode }) => {
? path.resolve(__dirname, './tsconfig.build.json')
: path.resolve(__dirname, './tsconfig.dev.json');
const CHUNK_SIZE_WARNING_LIMIT = 1024 * 1024; // 1MB
// Please don't increase this limit for main index chunk
// If it gets too big then find modules in the code base
// that can be loaded lazily, there are more!
const MAIN_CHUNK_SIZE_LIMIT = 4.1 * 1024 * 1024; // 4.1MB for main index chunk
const OTHER_CHUNK_SIZE_LIMIT = 15 * 1024 * 1024; // 5MB for other chunks
const checkers: Checkers = {
overlay: false,
};
@ -167,20 +175,82 @@ export default defineConfig(({ command, mode }) => {
outDir: 'build',
sourcemap: VITE_BUILD_SOURCEMAP === 'true',
rollupOptions: {
// Don't use manual chunks as it causes many issue
// including this one we wasted a lot of time on:
// https://github.com/rollup/rollup/issues/2793
output: {
manualChunks: (id) => {
if (id.includes('@scalar')) {
return 'scalar';
}
// Set chunk size warning limit (in bytes) - warns at 1MB
chunkSizeWarningLimit: CHUNK_SIZE_WARNING_LIMIT,
// Custom plugin to fail build if chunks exceed max size
plugins: [
{
name: 'chunk-size-limit',
generateBundle(_options, bundle) {
const oversizedChunks: string[] = [];
return null;
Object.entries(bundle).forEach(([fileName, chunk]) => {
if (chunk.type === 'chunk' && chunk.code) {
const size = Buffer.byteLength(chunk.code, 'utf8');
const isMainChunk = fileName.includes('index') && chunk.isEntry;
const sizeLimit = isMainChunk ? MAIN_CHUNK_SIZE_LIMIT : OTHER_CHUNK_SIZE_LIMIT;
const limitType = isMainChunk ? 'main' : 'other';
if (size > sizeLimit) {
oversizedChunks.push(`${fileName} (${limitType}): ${(size / 1024 / 1024).toFixed(2)}MB (limit: ${(sizeLimit / 1024 / 1024).toFixed(0)}MB)`);
}
}
});
if (oversizedChunks.length > 0) {
const errorMessage = `Build failed: The following chunks exceed their size limits:\n${oversizedChunks.map(chunk => ` - ${chunk}`).join('\n')}`;
this.error(errorMessage);
}
}
},
// TODO; later - think about prefetching modules such
// as date time picker, phone input etc...
/*
{
name: 'add-prefetched-modules',
transformIndexHtml(html: string,
ctx: {
path: string;
filename: string;
server?: ViteDevServer;
bundle?: import('rollup').OutputBundle;
chunk?: import('rollup').OutputChunk;
}) {
const bundles = Object.keys(ctx.bundle ?? {});
let modernBundles = bundles.filter(
(bundle) => bundle.endsWith('.map') === false
);
// Remove existing files and concatenate them into link tags
const prefechBundlesString = modernBundles
.filter((bundle) => html.includes(bundle) === false)
.map((bundle) => `<link rel="prefetch" href="${ctx.server?.config.base}${bundle}">`)
.join('');
// Use regular expression to get the content within <head> </head>
const headContent = html.match(/<head>([\s\S]*)<\/head>/)?.[1] ?? '';
// Insert the content of prefetch into the head
const newHeadContent = `${headContent}${prefechBundlesString}`;
// Replace the original head
html = html.replace(
/<head>([\s\S]*)<\/head>/,
`<head>${newHeadContent}</head>`
);
return html;
},
},
modulePreload: {
resolveDependencies: (filename, deps, { hostId }) => {
return deps.filter(dep => !dep.includes('scalar'));
},
}*/
]
}
},
},

View File

@ -1,7 +1,7 @@
export enum DatabaseEventAction {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
DELETED = 'DELETED',
DESTROYED = 'DESTROYED',
RESTORED = 'RESTORED',
CREATED = 'created',
UPDATED = 'updated',
DELETED = 'deleted',
DESTROYED = 'destroyed',
RESTORED = 'restored',
}