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

View File

@ -1,4 +1,4 @@
import { BlockNoteEditor } from '@blocknote/core'; import { BlockNoteEditor, PartialBlock } from '@blocknote/core';
import { import {
PDFExporter, PDFExporter,
pdfDefaultSchemaMappings, pdfDefaultSchemaMappings,
@ -7,9 +7,13 @@ import { pdf } from '@react-pdf/renderer';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
export const exportBlockNoteEditorToPdf = async ( export const exportBlockNoteEditorToPdf = async (
editor: BlockNoteEditor, parsedBody: PartialBlock[],
filename: string, filename: string,
) => { ) => {
const editor = BlockNoteEditor.create({
initialContent: parsedBody,
});
const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings);
const pdfDocument = await exporter.toReactPDFDocument(editor.document); 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 { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly'; import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/useIsRecordReadOnly';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; 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/core/fonts/inter.css';
import '@blocknote/mantine/style.css'; import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react'; import { useCreateBlockNote } from '@blocknote/react';

View File

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

View File

@ -18,7 +18,6 @@ import { AuthTokenPair } from '~/generated/graphql';
import { logDebug } from '~/utils/logDebug'; import { logDebug } from '~/utils/logDebug';
import { i18n } from '@lingui/core'; import { i18n } from '@lingui/core';
import { captureException } from '@sentry/react';
import { GraphQLFormattedError } from 'graphql'; import { GraphQLFormattedError } from 'graphql';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { cookieStorage } from '~/utils/cookie-storage'; import { cookieStorage } from '~/utils/cookie-storage';
@ -160,7 +159,17 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
}, Path: ${graphQLError.path}`, }, Path: ${graphQLError.path}`,
); );
} }
captureException(graphQLError); 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 { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { lazy, Suspense } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { viewableRichTextComponentState } from '../states/viewableRichTextComponentState'; import { viewableRichTextComponentState } from '../states/viewableRichTextComponentState';
const ActivityRichTextEditor = lazy(() =>
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
default: module.ActivityRichTextEditor,
})),
);
const StyledContainer = styled.div` const StyledContainer = styled.div`
box-sizing: border-box; box-sizing: border-box;
margin: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(-2)}; margin: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(-2)};
@ -11,6 +20,20 @@ const StyledContainer = styled.div`
width: 100%; 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 = () => { export const CommandMenuEditRichTextPage = () => {
const { activityId, activityObjectNameSingular } = useRecoilValue( const { activityId, activityObjectNameSingular } = useRecoilValue(
viewableRichTextComponentState, viewableRichTextComponentState,
@ -27,10 +50,12 @@ export const CommandMenuEditRichTextPage = () => {
return ( return (
<StyledContainer> <StyledContainer>
<ActivityRichTextEditor <Suspense fallback={<LoadingSkeleton />}>
activityId={activityId} <ActivityRichTextEditor
activityObjectNameSingular={activityObjectNameSingular} activityId={activityId}
/> activityObjectNameSingular={activityObjectNameSingular}
/>
</Suspense>
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -1,5 +1,4 @@
import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect'; import { AppErrorBoundaryEffect } from '@/error-handler/components/internal/AppErrorBoundaryEffect';
import { captureException } from '@sentry/react';
import { ErrorInfo, ReactNode } from 'react'; import { ErrorInfo, ReactNode } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'; import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
@ -14,11 +13,17 @@ export const AppErrorBoundary = ({
FallbackComponent, FallbackComponent,
resetOnLocationChange = true, resetOnLocationChange = true,
}: AppErrorBoundaryProps) => { }: AppErrorBoundaryProps) => {
const handleError = (error: Error, info: ErrorInfo) => { const handleError = async (error: Error, info: ErrorInfo) => {
captureException(error, (scope) => { try {
scope.setExtras({ info }); const { captureException } = await import('@sentry/react');
return scope; 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 = () => { const handleReset = () => {

View File

@ -1,9 +1,3 @@
import {
browserTracingIntegration,
init,
replayIntegration,
setUser,
} from '@sentry/react';
import { isNonEmptyString } from '@sniptt/guards'; import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
@ -27,38 +21,74 @@ export const SentryInitEffect = () => {
const [isSentryUserDefined, setIsSentryUserDefined] = useState(false); const [isSentryUserDefined, setIsSentryUserDefined] = useState(false);
useEffect(() => { useEffect(() => {
if ( const initializeSentry = async () => {
!isSentryInitializing && if (
isNonEmptyString(sentryConfig?.dsn) && isNonEmptyString(sentryConfig?.dsn) &&
!isSentryInitialized !isSentryInitialized &&
) { !isSentryInitializing
setIsSentryInitializing(true); ) {
init({ setIsSentryInitializing(true);
environment: sentryConfig?.environment ?? undefined,
release: sentryConfig?.release ?? undefined,
dsn: sentryConfig?.dsn,
integrations: [browserTracingIntegration({}), replayIntegration()],
tracePropagationTargets: ['localhost:3001', REACT_APP_SERVER_BASE_URL],
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
setIsSentryInitialized(true); try {
setIsSentryInitializing(false); const { init, browserTracingIntegration, replayIntegration } =
} await import('@sentry/react');
if (isDefined(currentUser) && !isSentryUserDefined) { init({
setUser({ environment: sentryConfig?.environment ?? undefined,
email: currentUser?.email, release: sentryConfig?.release ?? undefined,
id: currentUser?.id, dsn: sentryConfig?.dsn,
workspaceId: currentWorkspace?.id, integrations: [browserTracingIntegration({}), replayIntegration()],
workspaceMemberId: currentWorkspaceMember?.id, tracePropagationTargets: [
}); 'localhost:3001',
setIsSentryUserDefined(true); REACT_APP_SERVER_BASE_URL,
} else { ],
setUser(null); 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);
}
}
};
const updateSentryUser = async () => {
if (
isSentryInitialized &&
isDefined(currentUser) &&
!isSentryUserDefined
) {
try {
const { setUser } = await import('@sentry/react');
setUser({
email: currentUser?.email,
id: currentUser?.id,
workspaceId: currentWorkspace?.id,
workspaceMemberId: currentWorkspaceMember?.id,
});
setIsSentryUserDefined(true);
} 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, sentryConfig,
isSentryInitialized, isSentryInitialized,
@ -68,5 +98,6 @@ export const SentryInitEffect = () => {
currentWorkspaceMember, currentWorkspaceMember,
isSentryUserDefined, isSentryUserDefined,
]); ]);
return <></>; return <></>;
}; };

View File

@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { useObjectNamePluralFromSingular } from '../useObjectNamePluralFromSingular'; import { useObjectNamePluralFromSingular } from '../useObjectNamePluralFromSingular';
describe('useObjectNamePluralFromSingular', () => { describe('useObjectNamePluralFromSingular', () => {
@ -8,7 +9,13 @@ describe('useObjectNamePluralFromSingular', () => {
const { result } = renderHook( const { result } = renderHook(
() => useObjectNamePluralFromSingular({ objectNameSingular: 'person' }), () => 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 { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil'; import { RecoilRoot } from 'recoil';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { useObjectNameSingularFromPlural } from '../useObjectNameSingularFromPlural'; import { useObjectNameSingularFromPlural } from '../useObjectNameSingularFromPlural';
describe('useObjectNameSingularFromPlural', () => { describe('useObjectNameSingularFromPlural', () => {
@ -8,7 +9,13 @@ describe('useObjectNameSingularFromPlural', () => {
const { result } = renderHook( const { result } = renderHook(
() => useObjectNameSingularFromPlural({ objectNamePlural: 'people' }), () => 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 { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useLoadMockedObjectMetadataItems = () => { export const useLoadMockedObjectMetadataItems = () => {
const loadMockedObjectMetadataItems = useRecoilCallback( const loadMockedObjectMetadataItems = useRecoilCallback(
({ set, snapshot }) => ({ set, snapshot }) =>
() => { async () => {
const { generatedMockObjectMetadataItems } = await import(
'~/testing/mock-data/generatedMockObjectMetadataItems'
);
if ( if (
!isDeeplyEqual( !isDeeplyEqual(
snapshot.getLoadable(objectMetadataItemsState).getValue(), snapshot.getLoadable(objectMetadataItemsState).getValue(),

View File

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

View File

@ -1,33 +1,20 @@
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
export const useObjectNameSingularFromPlural = ({ export const useObjectNameSingularFromPlural = ({
objectNamePlural, objectNamePlural,
}: { }: {
objectNamePlural: string; objectNamePlural: string;
}) => { }) => {
const currentWorkspace = useRecoilValue(currentWorkspaceState); const objectMetadataItem = useRecoilValue(
let objectMetadataItem = useRecoilValue(
objectMetadataItemFamilySelector({ objectMetadataItemFamilySelector({
objectName: objectNamePlural, objectName: objectNamePlural,
objectNameType: 'plural', objectNameType: 'plural',
}), }),
); );
if (!isWorkspaceActiveOrSuspended(currentWorkspace)) {
objectMetadataItem =
generatedMockObjectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.namePlural === objectNamePlural,
) ?? null;
}
if (!isDefined(objectMetadataItem)) { if (!isDefined(objectMetadataItem)) {
throw new Error( throw new Error(
`Object metadata item not found for ${objectNamePlural} object`, `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 { FormSelectFieldInput } from '@/object-record/record-field/form-types/components/FormSelectFieldInput';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent'; import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries'; 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 { IconCircleOff, IconComponentProps } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input'; import { SelectOption } from 'twenty-ui/input';

View File

@ -1,6 +1,6 @@
import { useRichTextV2FieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRichTextV2FieldDisplay'; import { useRichTextV2FieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRichTextV2FieldDisplay';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText'; 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'; import { isDefined, parseJson } from 'twenty-shared/utils';
export const RichTextV2FieldDisplay = () => { 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 { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue'; 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 { isNonEmptyString } from '@sniptt/guards';
import { FieldContext } from '../../contexts/FieldContext'; import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; 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 { FieldRichTextValue } from '@/object-record/record-field/types/FieldMetadata';
import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; 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 { isDefined, parseJson } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext'; 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 { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2'; import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isFieldRichTextV2Value } from '@/object-record/record-field/types/guards/isFieldRichTextValueV2'; 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 { isNonEmptyString } from '@sniptt/guards';
import { FieldContext } from '../../contexts/FieldContext'; import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; 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 { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useRichTextCommandMenu } from '@/command-menu/hooks/useRichTextCommandMenu'; import { useRichTextCommandMenu } from '@/command-menu/hooks/useRichTextCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
@ -8,11 +8,19 @@ import {
FieldInputEvent, FieldInputEvent,
} from '@/object-record/record-field/types/FieldInputEvent'; } from '@/object-record/record-field/types/FieldInputEvent';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2'; 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 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 { IconLayoutSidebarLeftCollapse } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input'; import { FloatingIconButton } from 'twenty-ui/input';
const ActivityRichTextEditor = lazy(() =>
import('@/activities/components/ActivityRichTextEditor').then((module) => ({
default: module.ActivityRichTextEditor,
})),
);
export type RichTextFieldInputProps = { export type RichTextFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent; onClickOutside?: FieldInputClickOutsideEvent;
onCancel?: () => void; onCancel?: () => void;
@ -38,6 +46,19 @@ const StyledCollapseButton = styled.div`
display: flex; 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 = ({ export const RichTextFieldInput = ({
targetableObject, targetableObject,
onClickOutside, onClickOutside,
@ -70,10 +91,12 @@ export const RichTextFieldInput = ({
return ( return (
<StyledContainer ref={containerRef}> <StyledContainer ref={containerRef}>
<ActivityRichTextEditor <Suspense fallback={<LoadingSkeleton />}>
activityId={targetableObject.id} <ActivityRichTextEditor
activityObjectNameSingular={targetableObject.targetObjectNameSingular} activityId={targetableObject.id}
/> activityObjectNameSingular={targetableObject.targetObjectNameSingular}
/>
</Suspense>
<StyledCollapseButton> <StyledCollapseButton>
<FloatingIconButton <FloatingIconButton
Icon={IconLayoutSidebarLeftCollapse} Icon={IconLayoutSidebarLeftCollapse}

View File

@ -1,4 +1,5 @@
import { Calendar } from '@/activities/calendar/components/Calendar'; import { Calendar } from '@/activities/calendar/components/Calendar';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { EmailThreads } from '@/activities/emails/components/EmailThreads'; import { EmailThreads } from '@/activities/emails/components/EmailThreads';
import { Attachments } from '@/activities/files/components/Attachments'; import { Attachments } from '@/activities/files/components/Attachments';
import { Notes } from '@/activities/notes/components/Notes'; 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 { ListenRecordUpdatesEffect } from '@/subscription/components/ListenUpdatesEffect';
import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer'; import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/ShowPageActivityContainer';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId'; import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { WorkflowRunVisualizer } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizer';
import { WorkflowRunVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowRunVisualizerEffect'; 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 { WorkflowVersionVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/workflow-diagram/components/WorkflowVisualizer';
import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect'; import { WorkflowVisualizerEffect } from '@/workflow/workflow-diagram/components/WorkflowVisualizerEffect';
import { WorkflowRunVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowRunVisualizerComponentInstanceContext'; import { WorkflowRunVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowRunVisualizerComponentInstanceContext';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; 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 }>` const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
background: ${({ theme, isInRightDrawer }) => background: ${({ theme, isInRightDrawer }) =>
@ -34,6 +34,15 @@ const StyledGreyBox = styled.div<{ isInRightDrawer?: boolean }>`
isInRightDrawer ? theme.spacing(4) : ''}; 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 = { type CardComponentProps = {
targetableObject: Pick< targetableObject: Pick<
ActivityTargetableObject, ActivityTargetableObject,
@ -44,6 +53,48 @@ type CardComponentProps = {
type CardComponentType = (props: CardComponentProps) => JSX.Element | null; 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> = { export const CardComponents: Record<CardType, CardComponentType> = {
[CardType.TimelineCard]: ({ targetableObject, isInRightDrawer }) => ( [CardType.TimelineCard]: ({ targetableObject, isInRightDrawer }) => (
<TimelineActivities <TimelineActivities
@ -95,7 +146,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
}} }}
> >
<WorkflowVisualizerEffect workflowId={targetableObject.id} /> <WorkflowVisualizerEffect workflowId={targetableObject.id} />
<WorkflowVisualizer workflowId={targetableObject.id} /> <Suspense fallback={<LoadingSkeleton />}>
<WorkflowVisualizer workflowId={targetableObject.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider> </WorkflowVisualizerComponentInstanceContext.Provider>
); );
}, },
@ -112,7 +165,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
<WorkflowVersionVisualizerEffect <WorkflowVersionVisualizerEffect
workflowVersionId={targetableObject.id} workflowVersionId={targetableObject.id}
/> />
<WorkflowVersionVisualizer workflowVersionId={targetableObject.id} /> <Suspense fallback={<LoadingSkeleton />}>
<WorkflowVersionVisualizer workflowVersionId={targetableObject.id} />
</Suspense>
</WorkflowVisualizerComponentInstanceContext.Provider> </WorkflowVisualizerComponentInstanceContext.Provider>
); );
}, },
@ -139,7 +194,9 @@ export const CardComponents: Record<CardType, CardComponentType> = {
recordId={targetableObject.id} recordId={targetableObject.id}
listenedFields={['status', 'output']} listenedFields={['status', 'output']}
/> />
<WorkflowRunVisualizer workflowRunId={targetableObject.id} /> <Suspense fallback={<LoadingSkeleton />}>
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
</Suspense>
</WorkflowRunVisualizerComponentInstanceContext.Provider> </WorkflowRunVisualizerComponentInstanceContext.Provider>
</WorkflowVisualizerComponentInstanceContext.Provider> </WorkflowVisualizerComponentInstanceContext.Provider>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import dagre from '@dagrejs/dagre';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { Edge, Node } from '@xyflow/react'; import { Edge, Node } from '@xyflow/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
@ -21,81 +20,91 @@ export const SettingsDataModelOverviewEffect = ({
useFilteredObjectMetadataItems(); useFilteredObjectMetadataItems();
useEffect(() => { useEffect(() => {
const g = new dagre.graphlib.Graph(); const loadDagreAndLayout = async () => {
g.setGraph({ rankdir: 'LR' }); const dagre = await import('@dagrejs/dagre');
g.setDefaultEdgeLabel(() => ({}));
const edges: Edge[] = []; const g = new dagre.default.graphlib.Graph();
const nodes = []; g.setGraph({ rankdir: 'LR' });
let i = 0; g.setDefaultEdgeLabel(() => ({}));
for (const object of items) {
nodes.push({
id: object.namePlural,
width: 220,
height: 100,
position: { x: i * 300, y: 0 },
data: object,
type: 'object',
});
g.setNode(object.namePlural, { width: 220, height: 100 });
for (const field of object.fields) { const edges: Edge[] = [];
if ( const nodes: Node[] = [];
isDefined(field.relationDefinition) && let i = 0;
isDefined( for (const object of items) {
items.find( nodes.push({
(x) => x.id === field.relationDefinition?.targetObjectMetadata.id, id: object.namePlural,
), width: 220,
) height: 100,
) { position: { x: i * 300, y: 0 },
const sourceObj = data: object,
field.relationDefinition?.sourceObjectMetadata.namePlural; type: 'object',
const targetObj = });
field.relationDefinition?.targetObjectMetadata.namePlural; g.setNode(object.namePlural, { width: 220, height: 100 });
edges.push({ for (const field of object.fields) {
id: `${sourceObj}-${targetObj}`, if (
source: object.namePlural, isDefined(field.relationDefinition) &&
sourceHandle: `${field.id}-right`, isDefined(
target: field.relationDefinition.targetObjectMetadata.namePlural, items.find(
targetHandle: `${field.relationDefinition.targetObjectMetadata}-left`, (x) =>
type: 'smoothstep', x.id === field.relationDefinition?.targetObjectMetadata.id,
style: { ),
strokeWidth: 1, )
stroke: theme.color.gray, ) {
}, const sourceObj =
markerEnd: 'marker', field.relationDefinition?.sourceObjectMetadata.namePlural;
markerStart: 'marker', const targetObj =
data: { field.relationDefinition?.targetObjectMetadata.namePlural;
sourceField: field.id,
targetField: field.relationDefinition.targetFieldMetadata.id, edges.push({
relation: field.relationDefinition.direction, id: `${sourceObj}-${targetObj}`,
sourceObject: sourceObj, source: object.namePlural,
targetObject: targetObj, sourceHandle: `${field.id}-right`,
}, target: field.relationDefinition.targetObjectMetadata.namePlural,
}); targetHandle: `${field.relationDefinition.targetObjectMetadata}-left`,
if (!isUndefinedOrNull(sourceObj) && !isUndefinedOrNull(targetObj)) { type: 'smoothstep',
g.setEdge(sourceObj, targetObj); style: {
strokeWidth: 1,
stroke: theme.color.gray,
},
markerEnd: 'marker',
markerStart: 'marker',
data: {
sourceField: field.id,
targetField: field.relationDefinition.targetFieldMetadata.id,
relation: field.relationDefinition.direction,
sourceObject: sourceObj,
targetObject: targetObj,
},
});
if (
!isUndefinedOrNull(sourceObj) &&
!isUndefinedOrNull(targetObj)
) {
g.setEdge(sourceObj, targetObj);
}
} }
} }
i++;
} }
i++;
}
dagre.layout(g); dagre.default.layout(g);
nodes.forEach((node) => { nodes.forEach((node) => {
const nodeWithPosition = g.node(node.id); const nodeWithPosition = g.node(node.id);
node.position = { node.position = {
// We are shifting the dagre node position (anchor=center center) to the top left // 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). // so it matches the React Flow node anchor point (top left).
x: nodeWithPosition.x - node.width / 2, x: nodeWithPosition.x - (node.width ?? 0) / 2,
y: nodeWithPosition.y - node.height / 2, y: nodeWithPosition.y - (node.height ?? 0) / 2,
}; };
}); });
setNodes(nodes); setNodes(nodes);
setEdges(edges); setEdges(edges);
};
loadDagreAndLayout();
}, [items, setEdges, setNodes, theme]); }, [items, setEdges, setNodes, theme]);
return <></>; 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 { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode'; 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 { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState';
import { Dispatch, SetStateAction, useState } from 'react';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode'; import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath';
export type ServerlessFunctionNewFormValues = { export type ServerlessFunctionNewFormValues = {
name: string; 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 { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; import { useRecoilValue } from 'recoil';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
import { IconSearch, IconSettings } from 'twenty-ui/display'; import { IconSearch, IconSettings } from 'twenty-ui/display';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities'; import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
const StyledMainSection = styled(NavigationDrawerSection)` const StyledMainSection = styled(NavigationDrawerSection)`
min-height: fit-content; min-height: fit-content;
@ -35,6 +36,7 @@ export const SignInAppNavigationDrawerMock = ({
}: SignInAppNavigationDrawerMockProps) => { }: SignInAppNavigationDrawerMockProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { t } = useLingui(); const { t } = useLingui();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
return ( return (
<NavigationDrawer className={className} title={DEFAULT_WORKSPACE_NAME}> <NavigationDrawer className={className} title={DEFAULT_WORKSPACE_NAME}>
@ -57,7 +59,7 @@ export const SignInAppNavigationDrawerMock = ({
<NavigationDrawerSectionForObjectMetadataItems <NavigationDrawerSectionForObjectMetadataItems
sectionTitle={t`Workspace`} sectionTitle={t`Workspace`}
isRemote={false} isRemote={false}
objectMetadataItems={generatedMockObjectMetadataItems.filter((item) => objectMetadataItems={objectMetadataItems.filter((item) =>
WORKSPACE_FAVORITES.includes(item.nameSingular), WORKSPACE_FAVORITES.includes(item.nameSingular),
)} )}
/> />

View File

@ -1,12 +1,33 @@
import { useTheme } from '@emotion/react';
import React from 'react'; import React from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useRecoilState, useSetRecoilState } from 'recoil'; 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 { 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 { matchColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; 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; type SpreadsheetImportProviderProps = React.PropsWithChildren;
@ -36,11 +57,13 @@ export const SpreadsheetImportProvider = (
<> <>
{props.children} {props.children}
{spreadsheetImportDialog.isOpen && spreadsheetImportDialog.options && ( {spreadsheetImportDialog.isOpen && spreadsheetImportDialog.options && (
<SpreadsheetImport <React.Suspense fallback={<LoadingSkeleton />}>
onClose={handleClose} <SpreadsheetImport
// eslint-disable-next-line react/jsx-props-no-spreading onClose={handleClose}
{...spreadsheetImportDialog.options} // 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 styled from '@emotion/styled';
import { DateTime } from 'luxon'; 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 { 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 { AbsoluteDatePickerHeader } from '@/ui/input/components/internal/date/components/AbsoluteDatePickerHeader';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput'; import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
@ -13,8 +15,8 @@ import {
VariableDateViewFilterValueDirection, VariableDateViewFilterValueDirection,
VariableDateViewFilterValueUnit, VariableDateViewFilterValueUnit,
} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; } from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import { IconCalendarX } from 'twenty-ui/display'; import { IconCalendarX } from 'twenty-ui/display';
import { import {
@ -276,6 +278,20 @@ const StyledButton = styled(MenuItemLeftContent)`
justify-content: start; 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 = { type DateTimePickerProps = {
isRelative?: boolean; isRelative?: boolean;
hideHeaderInput?: boolean; hideHeaderInput?: boolean;
@ -306,6 +322,8 @@ type DateTimePickerProps = {
onClear?: () => void; onClear?: () => void;
}; };
const ReactDatePicker = lazy(() => import('react-datepicker'));
export const DateTimePicker = ({ export const DateTimePicker = ({
date, date,
onChange, onChange,
@ -322,6 +340,7 @@ export const DateTimePicker = ({
const internalDate = date ?? new Date(); const internalDate = date ?? new Date();
const { timeZone } = useContext(UserContext); const { timeZone } = useContext(UserContext);
const theme = useTheme();
const { closeDropdown: closeDropdownMonthSelect } = useDropdown( const { closeDropdown: closeDropdownMonthSelect } = useDropdown(
MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID,
@ -452,51 +471,80 @@ export const DateTimePicker = ({
return ( return (
<StyledContainer calendarDisabled={isRelative}> <StyledContainer calendarDisabled={isRelative}>
<div className={clearable ? 'clearable ' : ''}> <div className={clearable ? 'clearable ' : ''}>
<ReactDatePicker <Suspense
open={true} fallback={
selected={hasDate ? dateToUse : undefined} <StyledDatePickerFallback>
selectedDates={selectedDates} <SkeletonTheme
openToDate={hasDate ? dateToUse : new Date()} baseColor={theme.background.tertiary}
disabledKeyboardNavigation highlightColor={theme.background.transparent.lighter}
onChange={handleDateChange as any} borderRadius={4}
customInput={ >
<DateTimeInput <Skeleton
date={internalDate} width={200}
isDateTimeInput={isDateTimeInput} height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
onChange={onChange} />
userTimezone={timeZone} <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>
} }
renderCustomHeader={({ >
prevMonthButtonDisabled, <ReactDatePicker
nextMonthButtonDisabled, open={true}
}) => selected={hasDate ? dateToUse : undefined}
isRelative ? ( selectedDates={selectedDates}
<RelativeDatePickerHeader openToDate={hasDate ? dateToUse : new Date()}
direction={relativeDate?.direction ?? 'PAST'} disabledKeyboardNavigation
amount={relativeDate?.amount} onChange={handleDateChange as any}
unit={relativeDate?.unit ?? 'DAY'} customInput={
onChange={onRelativeDateChange} <DateTimeInput
/>
) : (
<AbsoluteDatePickerHeader
date={internalDate} date={internalDate}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput} isDateTimeInput={isDateTimeInput}
timeZone={timeZone} onChange={onChange}
hideInput={hideHeaderInput} userTimezone={timeZone}
/> />
) }
} renderCustomHeader={({
onSelect={handleDateSelect} prevMonthButtonDisabled,
selectsMultiple={isRelative} nextMonthButtonDisabled,
/> }) =>
isRelative ? (
<RelativeDatePickerHeader
direction={relativeDate?.direction ?? 'PAST'}
amount={relativeDate?.amount}
unit={relativeDate?.unit ?? 'DAY'}
onChange={onRelativeDateChange}
/>
) : (
<AbsoluteDatePickerHeader
date={internalDate}
onChange={onChange}
onChangeMonth={handleChangeMonth}
onChangeYear={handleChangeYear}
onAddMonth={handleAddMonth}
onSubtractMonth={handleSubtractMonth}
prevMonthButtonDisabled={prevMonthButtonDisabled}
nextMonthButtonDisabled={nextMonthButtonDisabled}
isDateTimeInput={isDateTimeInput}
timeZone={timeZone}
hideInput={hideHeaderInput}
/>
)
}
onSelect={handleDateSelect}
selectsMultiple={isRelative}
/>
</Suspense>
</div> </div>
{clearable && ( {clearable && (
<StyledButtonContainer onClick={handleClear}> <StyledButtonContainer onClick={handleClear}>

View File

@ -1,5 +1,5 @@
import { FlagComponent } from 'country-flag-icons/react/3x2'; import { FlagComponent } from 'country-flag-icons/react/3x2';
import { CountryCallingCode, CountryCode } from 'libphonenumber-js'; import type { CountryCallingCode, CountryCode } from 'libphonenumber-js';
export type Country = { export type Country = {
countryCode: CountryCode; 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 styled from '@emotion/styled';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; 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'; import { getFirstNonEmptyLineOfRichText } from '../getFirstNonEmptyLineOfRichText';
describe('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'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getFirstNonEmptyLineOfRichText = ( export const getFirstNonEmptyLineOfRichText = (

View File

@ -111,7 +111,6 @@ export const Default: Story = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
expect(await canvas.findByText('Send Email')).toBeVisible(); expect(await canvas.findByText('Send Email')).toBeVisible();
expect(await canvas.findByText('Account')).toBeVisible(); expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible(); expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).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 { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { ObjectMetadataItemsLoadEffect } from '@/object-metadata/components/ObjectMetadataItemsLoadEffect';
import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider'; import { PreComputedChipGeneratorsProvider } from '@/object-metadata/components/PreComputedChipGeneratorsProvider';
import { useLoadMockedObjectMetadataItems } from '@/object-metadata/hooks/useLoadMockedObjectMetadataItems';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { mockedUserData } from '~/testing/mock-data/users'; import { mockedUserData } from '~/testing/mock-data/users';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
@ -19,15 +19,22 @@ export const ObjectMetadataItemsDecorator: Decorator = (Story) => {
const setCurrentUser = useSetRecoilState(currentUserState); const setCurrentUser = useSetRecoilState(currentUserState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const { loadMockedObjectMetadataItems } = useLoadMockedObjectMetadataItems();
useEffect(() => { useEffect(() => {
setCurrentWorkspaceMember(mockWorkspaceMembers[0]); setCurrentWorkspaceMember(mockWorkspaceMembers[0]);
setCurrentUser(mockedUserData); setCurrentUser(mockedUserData);
setCurrentUserWorkspace(mockedUserData.currentUserWorkspace); setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
}, [setCurrentUser, setCurrentWorkspaceMember, setCurrentUserWorkspace]); loadMockedObjectMetadataItems();
}, [
setCurrentUser,
setCurrentWorkspaceMember,
setCurrentUserWorkspace,
loadMockedObjectMetadataItems,
]);
return ( return (
<> <>
<ObjectMetadataItemsLoadEffect />
<PreComputedChipGeneratorsProvider> <PreComputedChipGeneratorsProvider>
{!!objectMetadataItems.length && <Story />} {!!objectMetadataItems.length && <Story />}
</PreComputedChipGeneratorsProvider> </PreComputedChipGeneratorsProvider>

View File

@ -37,6 +37,14 @@ export default defineConfig(({ command, mode }) => {
? path.resolve(__dirname, './tsconfig.build.json') ? path.resolve(__dirname, './tsconfig.build.json')
: path.resolve(__dirname, './tsconfig.dev.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 = { const checkers: Checkers = {
overlay: false, overlay: false,
}; };
@ -167,20 +175,82 @@ export default defineConfig(({ command, mode }) => {
outDir: 'build', outDir: 'build',
sourcemap: VITE_BUILD_SOURCEMAP === 'true', sourcemap: VITE_BUILD_SOURCEMAP === 'true',
rollupOptions: { 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: { output: {
manualChunks: (id) => { // Set chunk size warning limit (in bytes) - warns at 1MB
if (id.includes('@scalar')) { chunkSizeWarningLimit: CHUNK_SIZE_WARNING_LIMIT,
return 'scalar'; // 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;
modulePreload: { const sizeLimit = isMainChunk ? MAIN_CHUNK_SIZE_LIMIT : OTHER_CHUNK_SIZE_LIMIT;
resolveDependencies: (filename, deps, { hostId }) => { const limitType = isMainChunk ? 'main' : 'other';
return deps.filter(dep => !dep.includes('scalar'));
}, 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;
},
}*/
]
}
}, },
}, },

View File

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