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:
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 [];
|
||||
|
||||
|
||||
@ -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}`,
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<ActivityRichTextEditor
|
||||
activityId={activityId}
|
||||
activityObjectNameSingular={activityObjectNameSingular}
|
||||
/>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<ActivityRichTextEditor
|
||||
activityId={activityId}
|
||||
activityObjectNameSingular={activityObjectNameSingular}
|
||||
/>
|
||||
</Suspense>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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) => {
|
||||
captureException(error, (scope) => {
|
||||
scope.setExtras({ info });
|
||||
return scope;
|
||||
});
|
||||
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 = () => {
|
||||
|
||||
@ -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,38 +21,74 @@ export const SentryInitEffect = () => {
|
||||
const [isSentryUserDefined, setIsSentryUserDefined] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isSentryInitializing &&
|
||||
isNonEmptyString(sentryConfig?.dsn) &&
|
||||
!isSentryInitialized
|
||||
) {
|
||||
setIsSentryInitializing(true);
|
||||
init({
|
||||
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,
|
||||
});
|
||||
const initializeSentry = async () => {
|
||||
if (
|
||||
isNonEmptyString(sentryConfig?.dsn) &&
|
||||
!isSentryInitialized &&
|
||||
!isSentryInitializing
|
||||
) {
|
||||
setIsSentryInitializing(true);
|
||||
|
||||
setIsSentryInitialized(true);
|
||||
setIsSentryInitializing(false);
|
||||
}
|
||||
try {
|
||||
const { init, browserTracingIntegration, replayIntegration } =
|
||||
await import('@sentry/react');
|
||||
|
||||
if (isDefined(currentUser) && !isSentryUserDefined) {
|
||||
setUser({
|
||||
email: currentUser?.email,
|
||||
id: currentUser?.id,
|
||||
workspaceId: currentWorkspace?.id,
|
||||
workspaceMemberId: currentWorkspaceMember?.id,
|
||||
});
|
||||
setIsSentryUserDefined(true);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
init({
|
||||
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);
|
||||
} 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,
|
||||
isSentryInitialized,
|
||||
@ -68,5 +98,6 @@ export const SentryInitEffect = () => {
|
||||
currentWorkspaceMember,
|
||||
isSentryUserDefined,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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}>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={targetableObject.targetObjectNameSingular}
|
||||
/>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<ActivityRichTextEditor
|
||||
activityId={targetableObject.id}
|
||||
activityObjectNameSingular={targetableObject.targetObjectNameSingular}
|
||||
/>
|
||||
</Suspense>
|
||||
<StyledCollapseButton>
|
||||
<FloatingIconButton
|
||||
Icon={IconLayoutSidebarLeftCollapse}
|
||||
|
||||
@ -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} />
|
||||
<WorkflowVisualizer 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}
|
||||
/>
|
||||
<WorkflowVersionVisualizer 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']}
|
||||
/>
|
||||
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<WorkflowRunVisualizer workflowRunId={targetableObject.id} />
|
||||
</Suspense>
|
||||
</WorkflowRunVisualizerComponentInstanceContext.Provider>
|
||||
</WorkflowVisualizerComponentInstanceContext.Provider>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,11 +203,12 @@ export const WorkerMetricsGraph = ({
|
||||
.filter(([key]) => key !== '__typename')
|
||||
.map(([key, value]) => ({
|
||||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
value: isNumber(value)
|
||||
? value
|
||||
: Array.isArray(value)
|
||||
? value.length
|
||||
: String(value),
|
||||
value:
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: Array.isArray(value)
|
||||
? value.length
|
||||
: String(value),
|
||||
}))}
|
||||
gridAutoColumns="1fr 1fr"
|
||||
labelAlign="left"
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,81 +20,91 @@ export const SettingsDataModelOverviewEffect = ({
|
||||
useFilteredObjectMetadataItems();
|
||||
|
||||
useEffect(() => {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR' });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
const loadDagreAndLayout = async () => {
|
||||
const dagre = await import('@dagrejs/dagre');
|
||||
|
||||
const edges: Edge[] = [];
|
||||
const nodes = [];
|
||||
let i = 0;
|
||||
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 });
|
||||
const g = new dagre.default.graphlib.Graph();
|
||||
g.setGraph({ rankdir: 'LR' });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const field of object.fields) {
|
||||
if (
|
||||
isDefined(field.relationDefinition) &&
|
||||
isDefined(
|
||||
items.find(
|
||||
(x) => x.id === field.relationDefinition?.targetObjectMetadata.id,
|
||||
),
|
||||
)
|
||||
) {
|
||||
const sourceObj =
|
||||
field.relationDefinition?.sourceObjectMetadata.namePlural;
|
||||
const targetObj =
|
||||
field.relationDefinition?.targetObjectMetadata.namePlural;
|
||||
const edges: Edge[] = [];
|
||||
const nodes: Node[] = [];
|
||||
let i = 0;
|
||||
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 });
|
||||
|
||||
edges.push({
|
||||
id: `${sourceObj}-${targetObj}`,
|
||||
source: object.namePlural,
|
||||
sourceHandle: `${field.id}-right`,
|
||||
target: field.relationDefinition.targetObjectMetadata.namePlural,
|
||||
targetHandle: `${field.relationDefinition.targetObjectMetadata}-left`,
|
||||
type: 'smoothstep',
|
||||
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);
|
||||
for (const field of object.fields) {
|
||||
if (
|
||||
isDefined(field.relationDefinition) &&
|
||||
isDefined(
|
||||
items.find(
|
||||
(x) =>
|
||||
x.id === field.relationDefinition?.targetObjectMetadata.id,
|
||||
),
|
||||
)
|
||||
) {
|
||||
const sourceObj =
|
||||
field.relationDefinition?.sourceObjectMetadata.namePlural;
|
||||
const targetObj =
|
||||
field.relationDefinition?.targetObjectMetadata.namePlural;
|
||||
|
||||
edges.push({
|
||||
id: `${sourceObj}-${targetObj}`,
|
||||
source: object.namePlural,
|
||||
sourceHandle: `${field.id}-right`,
|
||||
target: field.relationDefinition.targetObjectMetadata.namePlural,
|
||||
targetHandle: `${field.relationDefinition.targetObjectMetadata}-left`,
|
||||
type: 'smoothstep',
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
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 ?? 0) / 2,
|
||||
y: nodeWithPosition.y - (node.height ?? 0) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
};
|
||||
|
||||
loadDagreAndLayout();
|
||||
}, [items, setEdges, setNodes, theme]);
|
||||
|
||||
return <></>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -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 && (
|
||||
<SpreadsheetImport
|
||||
onClose={handleClose}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...spreadsheetImportDialog.options}
|
||||
/>
|
||||
<React.Suspense fallback={<LoadingSkeleton />}>
|
||||
<SpreadsheetImport
|
||||
onClose={handleClose}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...spreadsheetImportDialog.options}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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,51 +471,80 @@ export const DateTimePicker = ({
|
||||
return (
|
||||
<StyledContainer calendarDisabled={isRelative}>
|
||||
<div className={clearable ? 'clearable ' : ''}>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={hasDate ? dateToUse : undefined}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={hasDate ? dateToUse : new Date()}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange as any}
|
||||
customInput={
|
||||
<DateTimeInput
|
||||
date={internalDate}
|
||||
isDateTimeInput={isDateTimeInput}
|
||||
onChange={onChange}
|
||||
userTimezone={timeZone}
|
||||
/>
|
||||
<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>
|
||||
}
|
||||
renderCustomHeader={({
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) =>
|
||||
isRelative ? (
|
||||
<RelativeDatePickerHeader
|
||||
direction={relativeDate?.direction ?? 'PAST'}
|
||||
amount={relativeDate?.amount}
|
||||
unit={relativeDate?.unit ?? 'DAY'}
|
||||
onChange={onRelativeDateChange}
|
||||
/>
|
||||
) : (
|
||||
<AbsoluteDatePickerHeader
|
||||
>
|
||||
<ReactDatePicker
|
||||
open={true}
|
||||
selected={hasDate ? dateToUse : undefined}
|
||||
selectedDates={selectedDates}
|
||||
openToDate={hasDate ? dateToUse : new Date()}
|
||||
disabledKeyboardNavigation
|
||||
onChange={handleDateChange as any}
|
||||
customInput={
|
||||
<DateTimeInput
|
||||
date={internalDate}
|
||||
onChange={onChange}
|
||||
onChangeMonth={handleChangeMonth}
|
||||
onChangeYear={handleChangeYear}
|
||||
onAddMonth={handleAddMonth}
|
||||
onSubtractMonth={handleSubtractMonth}
|
||||
prevMonthButtonDisabled={prevMonthButtonDisabled}
|
||||
nextMonthButtonDisabled={nextMonthButtonDisabled}
|
||||
isDateTimeInput={isDateTimeInput}
|
||||
timeZone={timeZone}
|
||||
hideInput={hideHeaderInput}
|
||||
onChange={onChange}
|
||||
userTimezone={timeZone}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onSelect={handleDateSelect}
|
||||
selectsMultiple={isRelative}
|
||||
/>
|
||||
}
|
||||
renderCustomHeader={({
|
||||
prevMonthButtonDisabled,
|
||||
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>
|
||||
{clearable && (
|
||||
<StyledButtonContainer onClick={handleClear}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PartialBlock } from '@blocknote/core';
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { getFirstNonEmptyLineOfRichText } from '../getFirstNonEmptyLineOfRichText';
|
||||
|
||||
describe('getFirstNonEmptyLineOfRichText', () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PartialBlock } from '@blocknote/core';
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
export const getFirstNonEmptyLineOfRichText = (
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
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;
|
||||
}) {
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
modulePreload: {
|
||||
resolveDependencies: (filename, deps, { hostId }) => {
|
||||
return deps.filter(dep => !dep.includes('scalar'));
|
||||
},
|
||||
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;
|
||||
|
||||
|
||||
},
|
||||
}*/
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user