From a946c6a33de378c1cb6818efee8f0d891208ded7 Mon Sep 17 00:00:00 2001
From: sid0-0 <43578323+sid0-0@users.noreply.github.com>
Date: Thu, 3 Oct 2024 22:25:29 +0530
Subject: [PATCH 01/16] fix: validate emails in record-fields (#7245)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix: #7149
Introduced a minimal field validation framework for record-fields.
Currently only shows errors for email field.
---------
Co-authored-by: sid0-0
Co-authored-by: bosiraphael
Co-authored-by: Félix Malfait
---
.../input/components/EmailsFieldInput.tsx | 12 +++-
.../input/components/LinksFieldInput.tsx | 5 +-
.../input/components/MultiItemFieldInput.tsx | 26 ++++++-
.../validation-schemas/emailSchema.ts | 3 +
.../dropdown/components/DropdownMenuInput.tsx | 72 ++++++++++++-------
5 files changed, 87 insertions(+), 31 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/validation-schemas/emailSchema.ts
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx
index 32418cebc607..d933aeabcd06 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx
@@ -1,6 +1,7 @@
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
-import { useMemo } from 'react';
+import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema';
+import { useCallback, useMemo } from 'react';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput';
@@ -29,6 +30,14 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
});
};
+ const validateInput = useCallback(
+ (input: string) => ({
+ isValid: emailSchema.safeParse(input).success,
+ errorMessage: '',
+ }),
+ [],
+ );
+
const isPrimaryEmail = (index: number) => index === 0 && emails?.length > 1;
return (
@@ -38,6 +47,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
onCancel={onCancel}
placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails}
+ validateInput={validateInput}
renderItem={({
value: email,
index,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx
index 97bc9578c97c..e52cc95c041f 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx
@@ -51,7 +51,10 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
onCancel={onCancel}
placeholder="URL"
fieldMetadataType={FieldMetadataType.Links}
- validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
+ validateInput={(input) => ({
+ isValid: absoluteUrlSchema.safeParse(input).success,
+ errorMessage: '',
+ })}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({
value: link,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx
index a73e49498805..7e3e93ec2c48 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx
@@ -30,7 +30,7 @@ type MultiItemFieldInputProps = {
onPersist: (updatedItems: T[]) => void;
onCancel?: () => void;
placeholder: string;
- validateInput?: (input: string) => boolean;
+ validateInput?: (input: string) => { isValid: boolean; errorMessage: string };
formatInput?: (input: string) => T;
renderItem: (props: {
value: T;
@@ -74,8 +74,21 @@ export const MultiItemFieldInput = ({
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
const [itemToEditIndex, setItemToEditIndex] = useState(-1);
+ const [errorData, setErrorData] = useState({
+ isValid: true,
+ errorMessage: '',
+ });
const isAddingNewItem = itemToEditIndex === -1;
+ const handleOnChange = (value: string) => {
+ setInputValue(value);
+ if (!validateInput) return;
+
+ if (errorData.isValid) {
+ setErrorData(errorData);
+ }
+ };
+
const handleAddButtonClick = () => {
setItemToEditIndex(-1);
setIsInputDisplayed(true);
@@ -105,7 +118,13 @@ export const MultiItemFieldInput = ({
};
const handleSubmitInput = () => {
- if (validateInput !== undefined && !validateInput(inputValue)) return;
+ if (validateInput !== undefined) {
+ const validationData = validateInput(inputValue) ?? { isValid: true };
+ if (!validationData.isValid) {
+ setErrorData(validationData);
+ return;
+ }
+ }
const newItem = formatInput
? formatInput(inputValue)
@@ -160,6 +179,7 @@ export const MultiItemFieldInput = ({
placeholder={placeholder}
value={inputValue}
hotkeyScope={hotkeyScope}
+ hasError={!errorData.isValid}
renderInput={
renderInput
? (props) =>
@@ -170,7 +190,7 @@ export const MultiItemFieldInput = ({
})
: undefined
}
- onChange={(event) => setInputValue(event.target.value)}
+ onChange={(event) => handleOnChange(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={
`
+const StyledInput = styled.input<{
+ withRightComponent?: boolean;
+ hasError?: boolean;
+}>`
${TEXT_INPUT_STYLE}
- border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border: 1px solid ${({ theme, hasError }) =>
+ hasError ? theme.border.color.danger : theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
font-weight: ${({ theme }) => theme.font.weight.medium};
@@ -19,8 +23,10 @@ const StyledInput = styled.input<{ withRightComponent?: boolean }>`
width: 100%;
&:focus {
- border-color: ${({ theme }) => theme.color.blue};
- box-shadow: 0px 0px 0px 3px ${({ theme }) => RGBA(theme.color.blue, 0.1)};
+ ${({ theme, hasError = false }) => {
+ if (hasError) return '';
+ return `box-shadow: 0px 0px 0px 3px ${RGBA(theme.color.blue, 0.1)}`;
+ }};
}
${({ withRightComponent }) =>
@@ -44,6 +50,12 @@ const StyledRightContainer = styled.div`
transform: translateY(-50%);
`;
+const StyledErrorDiv = styled.div`
+ color: ${({ theme }) => theme.color.red};
+ padding: 0 ${({ theme }) => theme.spacing(2)}
+ ${({ theme }) => theme.spacing(1)};
+`;
+
type HTMLInputProps = InputHTMLAttributes;
export type DropdownMenuInputProps = HTMLInputProps & {
@@ -60,6 +72,8 @@ export type DropdownMenuInputProps = HTMLInputProps & {
autoFocus: HTMLInputProps['autoFocus'];
placeholder: HTMLInputProps['placeholder'];
}) => React.ReactNode;
+ error?: string | null;
+ hasError?: boolean;
};
export const DropdownMenuInput = forwardRef<
@@ -81,6 +95,8 @@ export const DropdownMenuInput = forwardRef<
onTab,
rightComponent,
renderInput,
+ error = '',
+ hasError = false,
},
ref,
) => {
@@ -99,28 +115,32 @@ export const DropdownMenuInput = forwardRef<
});
return (
-
- {renderInput ? (
- renderInput({
- value,
- onChange,
- autoFocus,
- placeholder,
- })
- ) : (
-
- )}
- {!!rightComponent && (
- {rightComponent}
- )}
-
+ <>
+
+ {renderInput ? (
+ renderInput({
+ value,
+ onChange,
+ autoFocus,
+ placeholder,
+ })
+ ) : (
+
+ )}
+ {!!rightComponent && (
+ {rightComponent}
+ )}
+
+ {error && {error}}
+ >
);
},
);
From e3ed57442065cdad41f8ec58e4d4abc9349b4826 Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Fri, 4 Oct 2024 02:01:03 +0530
Subject: [PATCH 02/16] minor fix - reset single entity search (#7420)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
minor follow up fix #7285
---------
Co-authored-by: Félix Malfait
---
packages/twenty-front/src/generated-metadata/graphql.ts | 2 ++
.../components/RecordBoardColumnHeader.tsx | 1 -
.../record-board-column/hooks/useAddNewCard.ts | 7 ++++++-
.../settings/data-model/constants/RelationTypes.ts | 8 ++++++++
.../components/SettingsDataModelFieldRelationForm.tsx | 6 +++++-
5 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 4f48fe60c424..64d5248023f6 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -992,6 +992,7 @@ export type RelationDefinition = {
/** Relation definition type */
export enum RelationDefinitionType {
+ ManyToMany = 'MANY_TO_MANY',
ManyToOne = 'MANY_TO_ONE',
OneToMany = 'ONE_TO_MANY',
OneToOne = 'ONE_TO_ONE'
@@ -999,6 +1000,7 @@ export enum RelationDefinitionType {
/** Type of the relation */
export enum RelationMetadataType {
+ ManyToMany = 'MANY_TO_MANY',
ManyToOne = 'MANY_TO_ONE',
OneToMany = 'ONE_TO_MANY',
OneToOne = 'ONE_TO_ONE'
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx
index d0d7e93de02c..e0ae827280d9 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx
@@ -93,7 +93,6 @@ export const RecordBoardColumnHeader = () => {
newRecord,
handleNewButtonClick,
handleCreateSuccess,
-
handleEntitySelect,
} = useColumnNewCardActions(columnDefinition.id);
const { isOpportunitiesCompanyFieldDisabled } =
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts
index f0921f82f380..97cb8c5d6052 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts
@@ -1,6 +1,7 @@
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext';
import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector';
+import { useEntitySelectSearch } from '@/object-record/relation-picker/hooks/useEntitySelectSearch';
import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect';
import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@@ -17,6 +18,9 @@ export const useAddNewCard = () => {
const columnContext = useContext(RecordBoardColumnContext);
const { createOneRecord, selectFieldMetadataItem } =
useContext(RecordBoardContext);
+ const { resetSearchFilter } = useEntitySelectSearch({
+ relationPickerScopeId: 'relation-picker',
+ });
const {
goBackToPreviousHotkeyScope,
@@ -132,11 +136,12 @@ export const useAddNewCard = () => {
company: null,
},
);
+ resetSearchFilter();
if (isOpportunity === true) {
goBackToPreviousHotkeyScope();
}
},
- [getColumnDefinitionId, goBackToPreviousHotkeyScope],
+ [getColumnDefinitionId, goBackToPreviousHotkeyScope, resetSearchFilter],
);
const handleCreate = (
diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/RelationTypes.ts b/packages/twenty-front/src/modules/settings/data-model/constants/RelationTypes.ts
index da5e9b8b9617..179f60fe8979 100644
--- a/packages/twenty-front/src/modules/settings/data-model/constants/RelationTypes.ts
+++ b/packages/twenty-front/src/modules/settings/data-model/constants/RelationTypes.ts
@@ -1,5 +1,6 @@
import {
IconComponent,
+ IllustrationIconManyToMany,
IllustrationIconOneToMany,
IllustrationIconOneToOne,
} from 'twenty-ui';
@@ -34,4 +35,11 @@ export const RELATION_TYPES: Record<
imageSrc: OneToManySvg,
isImageFlipped: true,
},
+ // Not supported yet
+ [RelationDefinitionType.ManyToMany]: {
+ label: 'Belongs to many',
+ Icon: IllustrationIconManyToMany,
+ imageSrc: OneToManySvg,
+ isImageFlipped: true,
+ },
};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
index 63305b53d04d..83f81326436a 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
@@ -66,7 +66,11 @@ const StyledInputsContainer = styled.div`
`;
const RELATION_TYPE_OPTIONS = Object.entries(RELATION_TYPES)
- .filter(([value]) => 'ONE_TO_ONE' !== value && 'MANY_TO_MANY' !== value)
+ .filter(
+ ([value]) =>
+ RelationDefinitionType.OneToOne !== value &&
+ RelationDefinitionType.ManyToMany !== value,
+ )
.map(([value, { label, Icon }]) => ({
label,
value: value as RelationType,
From 97eff774bdfd7590ed077d77b8dbfcd862d916f4 Mon Sep 17 00:00:00 2001
From: "gitstart-app[bot]"
<57568882+gitstart-app[bot]@users.noreply.github.com>
Date: Fri, 4 Oct 2024 10:45:25 +0200
Subject: [PATCH 03/16] Allow input and display of floats for Number fields
(#7340)
### Description
- We added a decimal field for a Number Field type in the settings
- We updated the Number Field type create a form with decimals input
- We are not implementing the dropdown present on the Figma because it
seems not related
### Demo
Fixes #6987
---------
Co-authored-by: gitstart-twenty
Co-authored-by: Marie Stoppa
---
.../hooks/__mocks__/useFieldMetadataItem.ts | 1 +
.../types/FieldMetadataItem.ts | 2 +-
...ormatFieldMetadataItemAsFieldDefinition.ts | 1 +
.../components/RecordBoardCard.tsx | 1 +
.../display/components/NumberFieldDisplay.tsx | 10 +-
.../meta-types/hooks/useNumberField.ts | 11 +-
.../record-field/types/FieldDefinition.ts | 3 +
.../numberFieldDefaultValueSchema.ts | 5 +
...SettingsDataModelFieldSettingsFormCard.tsx | 23 ++-
...ttingsDataModelFieldNumberDecimalInput.tsx | 168 ++++++++++++++++++
.../SettingsDataModelFieldNumberForm.tsx | 55 ++++++
...gsDataModelFieldNumberSettingsFormCard.tsx | 45 +++++
.../display/components/NumberDisplay.tsx | 7 +-
.../utils/mapViewFieldsToColumnDefinitions.ts | 1 +
.../__tests__/cast-as-integer-or-null.test.ts | 72 --------
.../__tests__/cast-as-number-or-null.test.ts | 72 ++++++++
...r-or-null.ts => cast-as-number-or-null.ts} | 12 +-
.../twenty-front/src/utils/format/number.ts | 10 +-
.../field-metadata-validation.service.ts | 48 +++++
.../field-metadata/field-metadata.module.ts | 7 +-
.../field-metadata/field-metadata.service.ts | 17 +-
.../field-metadata-settings.interface.ts | 1 +
22 files changed, 478 insertions(+), 94 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts
create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx
create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx
create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx
delete mode 100644 packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts
create mode 100644 packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts
rename packages/twenty-front/src/utils/{cast-as-integer-or-null.ts => cast-as-number-or-null.ts} (82%)
create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts
diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
index f3c3e93f1b2c..de52cbd8fe73 100644
--- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts
@@ -51,6 +51,7 @@ export const queries = {
${baseFields}
defaultValue
options
+ settings
}
}
`,
diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
index 61ce60263dfc..8a403a55a379 100644
--- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
@@ -17,7 +17,7 @@ export type FieldMetadataItemOption = {
export type FieldMetadataItem = Omit<
Field,
- '__typename' | 'defaultValue' | 'options' | 'settings' | 'relationDefinition'
+ '__typename' | 'defaultValue' | 'options' | 'relationDefinition'
> & {
__typename?: string;
defaultValue?: any;
diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
index 8ba9ebe23315..f372cd2eb3ac 100644
--- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
+++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts
@@ -54,5 +54,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({
metadata: fieldDefintionMetadata,
type: field.type,
}),
+ settings: field.settings,
};
};
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
index f7dde7fa911d..ef4aef726458 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx
@@ -338,6 +338,7 @@ export const RecordBoardCard = ({
metadata: fieldDefinition.metadata,
type: fieldDefinition.type,
}),
+ settings: fieldDefinition.settings,
},
useUpdateRecord: useUpdateOneRecordHook,
hotkeyScope: InlineCellHotkeyScope.InlineCell,
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
index 087a4117c47b..cb30dbed3776 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/NumberFieldDisplay.tsx
@@ -2,7 +2,11 @@ import { useNumberFieldDisplay } from '@/object-record/record-field/meta-types/h
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
export const NumberFieldDisplay = () => {
- const { fieldValue } = useNumberFieldDisplay();
-
- return ;
+ const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
+ return (
+
+ );
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
index 5bdceda11e73..097bcb8beef5 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts
@@ -5,10 +5,11 @@ import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecor
import { FieldNumberValue } from '@/object-record/record-field/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
+
import {
- canBeCastAsIntegerOrNull,
- castAsIntegerOrNull,
-} from '~/utils/cast-as-integer-or-null';
+ canBeCastAsNumberOrNull,
+ castAsNumberOrNull,
+} from '~/utils/cast-as-number-or-null';
import { FieldContext } from '../../contexts/FieldContext';
import { usePersistField } from '../../hooks/usePersistField';
@@ -32,11 +33,11 @@ export const useNumberField = () => {
const persistField = usePersistField();
const persistNumberField = (newValue: string) => {
- if (!canBeCastAsIntegerOrNull(newValue)) {
+ if (!canBeCastAsNumberOrNull(newValue)) {
return;
}
- const castedValue = castAsIntegerOrNull(newValue);
+ const castedValue = castAsNumberOrNull(newValue);
persistField(castedValue);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
index 16f05f2418d2..1d8107c17953 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts
@@ -16,4 +16,7 @@ export type FieldDefinition = {
infoTooltipContent?: string;
defaultValue?: any;
editButtonIcon?: IconComponent;
+ settings?: {
+ decimals?: number;
+ };
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts
new file mode 100644
index 000000000000..48981e4a5a34
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod';
+
+export const numberFieldDefaultValueSchema = z.object({
+ decimals: z.number().nullable(),
+});
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
index a71cc1654bcf..882b57f7d1c1 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx
@@ -11,6 +11,8 @@ import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
import { settingsDataModelFieldDateFormSchema } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateForm';
import { SettingsDataModelFieldDateSettingsFormCard } from '@/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard';
+import { settingsDataModelFieldNumberFormSchema } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
+import { SettingsDataModelFieldNumberSettingsFormCard } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard';
import { settingsDataModelFieldRelationFormSchema } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm';
import { SettingsDataModelFieldRelationSettingsFormCard } from '@/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationSettingsFormCard';
import {
@@ -52,6 +54,10 @@ const multiSelectFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.MultiSelect) })
.merge(settingsDataModelFieldMultiSelectFormSchema);
+const numberFieldFormSchema = z
+ .object({ type: z.literal(FieldMetadataType.Number) })
+ .merge(settingsDataModelFieldNumberFormSchema);
+
const otherFieldsFormSchema = z.object({
type: z.enum(
Object.keys(
@@ -63,6 +69,7 @@ const otherFieldsFormSchema = z.object({
FieldMetadataType.MultiSelect,
FieldMetadataType.Date,
FieldMetadataType.DateTime,
+ FieldMetadataType.Number,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
@@ -78,13 +85,17 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
relationFieldFormSchema,
selectFieldFormSchema,
multiSelectFieldFormSchema,
+ numberFieldFormSchema,
otherFieldsFormSchema,
],
);
type SettingsDataModelFieldSettingsFormCardProps = {
isCreatingField?: boolean;
- fieldMetadataItem: Pick &
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'isCustom'
+ > &
Partial>;
} & Pick;
@@ -163,6 +174,16 @@ export const SettingsDataModelFieldSettingsFormCard = ({
);
}
+ if (fieldMetadataItem.type === FieldMetadataType.Number) {
+ return (
+
+ );
+ }
+
if (
fieldMetadataItem.type === FieldMetadataType.Select ||
fieldMetadataItem.type === FieldMetadataType.MultiSelect
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx
new file mode 100644
index 000000000000..706bcea37154
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput.tsx
@@ -0,0 +1,168 @@
+import styled from '@emotion/styled';
+
+import { Button } from '@/ui/input/button/components/Button';
+import { TextInput } from '@/ui/input/components/TextInput';
+import { IconInfoCircle, IconMinus, IconPlus } from 'twenty-ui';
+import { castAsNumberOrNull } from '~/utils/cast-as-number-or-null';
+
+type SettingsDataModelFieldNumberDecimalsInputProps = {
+ value: number;
+ onChange: (value: number) => void;
+ disabled?: boolean;
+};
+
+const StyledCounterContainer = styled.div`
+ align-items: center;
+ background: ${({ theme }) => theme.background.noisy};
+ border: 1px solid ${({ theme }) => theme.border.color.medium};
+ border-radius: 4px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: ${({ theme }) => theme.spacing(1)};
+ justify-content: center;
+`;
+
+const StyledExampleText = styled.div`
+ color: ${({ theme }) => theme.font.color.primary};
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: ${({ theme }) => theme.font.weight.regular};
+`;
+
+const StyledCounterControlsIcons = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledCounterInnerContainer = styled.div`
+ align-items: center;
+ align-self: stretch;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(1)};
+ padding: ${({ theme }) => theme.spacing(2)};
+ height: 24px;
+`;
+
+const StyledTextInput = styled(TextInput)`
+ width: ${({ theme }) => theme.spacing(16)};
+ input {
+ width: ${({ theme }) => theme.spacing(16)};
+ height: ${({ theme }) => theme.spacing(6)};
+ text-align: center;
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ background: ${({ theme }) => theme.background.noisy};
+ }
+ input ~ div {
+ padding-right: ${({ theme }) => theme.spacing(0)};
+ border-radius: ${({ theme }) => theme.spacing(1)};
+ background: ${({ theme }) => theme.background.noisy};
+ }
+`;
+
+const StyledTitle = styled.div`
+ color: ${({ theme }) => theme.font.color.light};
+ font-size: ${({ theme }) => theme.font.size.xs};
+ font-weight: ${({ theme }) => theme.font.weight.semiBold};
+ margin-bottom: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledControlButton = styled(Button)`
+ height: ${({ theme }) => theme.spacing(6)};
+ width: ${({ theme }) => theme.spacing(6)};
+ padding: 0;
+ justify-content: center;
+ svg {
+ height: ${({ theme }) => theme.spacing(4)};
+ width: ${({ theme }) => theme.spacing(4)};
+ }
+`;
+
+const StyledInfoButton = styled(Button)`
+ height: ${({ theme }) => theme.spacing(6)};
+ width: ${({ theme }) => theme.spacing(6)};
+ padding: 0;
+ justify-content: center;
+ svg {
+ color: ${({ theme }) => theme.font.color.extraLight};
+ height: ${({ theme }) => theme.spacing(4)};
+ width: ${({ theme }) => theme.spacing(4)};
+ }
+`;
+
+const MIN_VALUE = 0;
+const MAX_VALUE = 100;
+export const SettingsDataModelFieldNumberDecimalsInput = ({
+ value,
+ onChange,
+ disabled,
+}: SettingsDataModelFieldNumberDecimalsInputProps) => {
+ const exampleValue = (1000).toFixed(value);
+
+ const handleIncrementCounter = () => {
+ if (value < MAX_VALUE) {
+ const newValue = value + 1;
+ onChange(newValue);
+ }
+ };
+
+ const handleDecrementCounter = () => {
+ if (value > MIN_VALUE) {
+ const newValue = value - 1;
+ onChange(newValue);
+ }
+ };
+
+ const handleTextInputChange = (value: string) => {
+ const castedNumber = castAsNumberOrNull(value);
+ if (castedNumber === null) {
+ onChange(MIN_VALUE);
+ return;
+ }
+
+ if (castedNumber < MIN_VALUE) {
+ return;
+ }
+
+ if (castedNumber > MAX_VALUE) {
+ onChange(MAX_VALUE);
+ return;
+ }
+ onChange(castedNumber);
+ };
+ return (
+ <>
+ Number of decimals
+
+
+ Example: {exampleValue}
+
+
+
+ handleTextInputChange(value)}
+ disabled={disabled}
+ />
+
+
+
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx
new file mode 100644
index 000000000000..3a80bf0e6104
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm.tsx
@@ -0,0 +1,55 @@
+import { Controller, useFormContext } from 'react-hook-form';
+import { z } from 'zod';
+
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { numberFieldDefaultValueSchema } from '@/object-record/record-field/validation-schemas/numberFieldDefaultValueSchema';
+import { SettingsDataModelFieldNumberDecimalsInput } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberDecimalInput';
+import { CardContent } from '@/ui/layout/card/components/CardContent';
+import { DEFAULT_DECIMAL_VALUE } from '~/utils/format/number';
+
+export const settingsDataModelFieldNumberFormSchema = z.object({
+ settings: numberFieldDefaultValueSchema,
+});
+
+export type SettingsDataModelFieldNumberFormValues = z.infer<
+ typeof settingsDataModelFieldNumberFormSchema
+>;
+
+type SettingsDataModelFieldNumberFormProps = {
+ disabled?: boolean;
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
+ >;
+};
+
+export const SettingsDataModelFieldNumberForm = ({
+ disabled,
+ fieldMetadataItem,
+}: SettingsDataModelFieldNumberFormProps) => {
+ const { control } = useFormContext();
+
+ return (
+
+ {
+ const count = value?.decimals ?? 0;
+
+ return (
+ onChange({ decimals: value })}
+ disabled={disabled}
+ >
+ );
+ }}
+ />
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx
new file mode 100644
index 000000000000..edea86760fbf
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx
@@ -0,0 +1,45 @@
+import styled from '@emotion/styled';
+
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
+import { SettingsDataModelFieldNumberForm } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
+import {
+ SettingsDataModelFieldPreviewCard,
+ SettingsDataModelFieldPreviewCardProps,
+} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
+
+type SettingsDataModelFieldNumberSettingsFormCardProps = {
+ disabled?: boolean;
+ fieldMetadataItem: Pick<
+ FieldMetadataItem,
+ 'icon' | 'label' | 'type' | 'defaultValue'
+ >;
+} & Pick;
+
+const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
+ display: grid;
+ flex: 1 1 100%;
+`;
+
+export const SettingsDataModelFieldNumberSettingsFormCard = ({
+ disabled,
+ fieldMetadataItem,
+ objectMetadataItem,
+}: SettingsDataModelFieldNumberSettingsFormCardProps) => {
+ return (
+
+ }
+ form={
+
+ }
+ />
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
index 1834e502a051..cef5ff6e0b81 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/NumberDisplay.tsx
@@ -4,8 +4,11 @@ import { EllipsisDisplay } from './EllipsisDisplay';
type NumberDisplayProps = {
value: string | number | null | undefined;
+ decimals?: number;
};
-export const NumberDisplay = ({ value }: NumberDisplayProps) => (
- {value && formatNumber(Number(value))}
+export const NumberDisplay = ({ value, decimals }: NumberDisplayProps) => (
+
+ {value && formatNumber(Number(value), decimals)}
+
);
diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
index 139ac042751f..cbf5f19b35ae 100644
--- a/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
+++ b/packages/twenty-front/src/modules/views/utils/mapViewFieldsToColumnDefinitions.ts
@@ -50,6 +50,7 @@ export const mapViewFieldsToColumnDefinitions = ({
isSortable: correspondingColumnDefinition.isSortable,
isFilterable: correspondingColumnDefinition.isFilterable,
defaultValue: correspondingColumnDefinition.defaultValue,
+ settings: correspondingColumnDefinition.settings,
} as ColumnDefinition;
})
.filter(isDefined);
diff --git a/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts b/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts
deleted file mode 100644
index cc077afdb27c..000000000000
--- a/packages/twenty-front/src/utils/__tests__/cast-as-integer-or-null.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import {
- canBeCastAsIntegerOrNull,
- castAsIntegerOrNull,
-} from '../cast-as-integer-or-null';
-
-describe('canBeCastAsIntegerOrNull', () => {
- it(`should return true if null`, () => {
- expect(canBeCastAsIntegerOrNull(null)).toBeTruthy();
- });
-
- it(`should return true if number`, () => {
- expect(canBeCastAsIntegerOrNull(9)).toBeTruthy();
- });
-
- it(`should return true if empty string`, () => {
- expect(canBeCastAsIntegerOrNull('')).toBeTruthy();
- });
-
- it(`should return true if integer string`, () => {
- expect(canBeCastAsIntegerOrNull('9')).toBeTruthy();
- });
-
- it(`should return false if undefined`, () => {
- expect(canBeCastAsIntegerOrNull(undefined)).toBeFalsy();
- });
-
- it(`should return false if non numeric string`, () => {
- expect(canBeCastAsIntegerOrNull('9a')).toBeFalsy();
- });
-
- it(`should return false if non numeric string #2`, () => {
- expect(canBeCastAsIntegerOrNull('a9a')).toBeFalsy();
- });
-
- it(`should return false if float`, () => {
- expect(canBeCastAsIntegerOrNull(0.9)).toBeFalsy();
- });
-
- it(`should return false if float string`, () => {
- expect(canBeCastAsIntegerOrNull('0.9')).toBeFalsy();
- });
-});
-
-describe('castAsIntegerOrNull', () => {
- it(`should cast null to null`, () => {
- expect(castAsIntegerOrNull(null)).toBe(null);
- });
-
- it(`should cast empty string to null`, () => {
- expect(castAsIntegerOrNull('')).toBe(null);
- });
-
- it(`should cast an integer to an integer`, () => {
- expect(castAsIntegerOrNull(9)).toBe(9);
- });
-
- it(`should cast an integer string to an integer`, () => {
- expect(castAsIntegerOrNull('9')).toBe(9);
- });
-
- it(`should throw if trying to cast a float string to an integer`, () => {
- expect(() => castAsIntegerOrNull('9.9')).toThrow(Error);
- });
-
- it(`should throw if trying to cast a non numeric string to an integer`, () => {
- expect(() => castAsIntegerOrNull('9.9a')).toThrow(Error);
- });
-
- it(`should throw if trying to cast an undefined to an integer`, () => {
- expect(() => castAsIntegerOrNull(undefined)).toThrow(Error);
- });
-});
diff --git a/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts b/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts
new file mode 100644
index 000000000000..082527de7ec7
--- /dev/null
+++ b/packages/twenty-front/src/utils/__tests__/cast-as-number-or-null.test.ts
@@ -0,0 +1,72 @@
+import {
+ canBeCastAsNumberOrNull,
+ castAsNumberOrNull,
+} from '../cast-as-number-or-null';
+
+describe('canBeCastAsNumberOrNull', () => {
+ it(`should return true if null`, () => {
+ expect(canBeCastAsNumberOrNull(null)).toBeTruthy();
+ });
+
+ it(`should return true if number`, () => {
+ expect(canBeCastAsNumberOrNull(9)).toBeTruthy();
+ });
+
+ it(`should return true if empty string`, () => {
+ expect(canBeCastAsNumberOrNull('')).toBeTruthy();
+ });
+
+ it(`should return true if integer string`, () => {
+ expect(canBeCastAsNumberOrNull('9')).toBeTruthy();
+ });
+
+ it(`should return false if undefined`, () => {
+ expect(canBeCastAsNumberOrNull(undefined)).toBeFalsy();
+ });
+
+ it(`should return false if non numeric string`, () => {
+ expect(canBeCastAsNumberOrNull('9a')).toBeFalsy();
+ });
+
+ it(`should return false if non numeric string #2`, () => {
+ expect(canBeCastAsNumberOrNull('a9a')).toBeFalsy();
+ });
+
+ it(`should return true if float`, () => {
+ expect(canBeCastAsNumberOrNull(0.9)).toBeTruthy();
+ });
+
+ it(`should return true if float string`, () => {
+ expect(canBeCastAsNumberOrNull('0.9')).toBeTruthy();
+ });
+});
+
+describe('castAsNumberOrNull', () => {
+ it(`should cast null to null`, () => {
+ expect(castAsNumberOrNull(null)).toBe(null);
+ });
+
+ it(`should cast empty string to null`, () => {
+ expect(castAsNumberOrNull('')).toBe(null);
+ });
+
+ it(`should cast an integer to an integer`, () => {
+ expect(castAsNumberOrNull(9)).toBe(9);
+ });
+
+ it(`should cast an integer string to an integer`, () => {
+ expect(castAsNumberOrNull('9')).toBe(9);
+ });
+
+ it(`should throw if trying to cast a float string to an integer`, () => {
+ expect(castAsNumberOrNull('9.9')).toBe(9.9);
+ });
+
+ it(`should throw if trying to cast a non numeric string to an integer`, () => {
+ expect(() => castAsNumberOrNull('9.9a')).toThrow(Error);
+ });
+
+ it(`should throw if trying to cast an undefined to an integer`, () => {
+ expect(() => castAsNumberOrNull(undefined)).toThrow(Error);
+ });
+});
diff --git a/packages/twenty-front/src/utils/cast-as-integer-or-null.ts b/packages/twenty-front/src/utils/cast-as-number-or-null.ts
similarity index 82%
rename from packages/twenty-front/src/utils/cast-as-integer-or-null.ts
rename to packages/twenty-front/src/utils/cast-as-number-or-null.ts
index 5cca0021dead..ef06e5b5a33e 100644
--- a/packages/twenty-front/src/utils/cast-as-integer-or-null.ts
+++ b/packages/twenty-front/src/utils/cast-as-number-or-null.ts
@@ -4,7 +4,7 @@ import { logError } from './logError';
const DEBUG_MODE = false;
-export const canBeCastAsIntegerOrNull = (
+export const canBeCastAsNumberOrNull = (
probableNumberOrNull: string | undefined | number | null,
): probableNumberOrNull is number | null => {
if (probableNumberOrNull === undefined) {
@@ -16,7 +16,7 @@ export const canBeCastAsIntegerOrNull = (
if (isNumber(probableNumberOrNull)) {
if (DEBUG_MODE) logError('typeof probableNumberOrNull === "number"');
- return Number.isInteger(probableNumberOrNull);
+ return true;
}
if (isNull(probableNumberOrNull)) {
@@ -39,8 +39,8 @@ export const canBeCastAsIntegerOrNull = (
return false;
}
- if (Number.isInteger(stringAsNumber)) {
- if (DEBUG_MODE) logError('Number.isInteger(stringAsNumber)');
+ if (isNumber(stringAsNumber)) {
+ if (DEBUG_MODE) logError('isNumber(stringAsNumber)');
return true;
}
@@ -49,10 +49,10 @@ export const canBeCastAsIntegerOrNull = (
return false;
};
-export const castAsIntegerOrNull = (
+export const castAsNumberOrNull = (
probableNumberOrNull: string | undefined | number | null,
): number | null => {
- if (canBeCastAsIntegerOrNull(probableNumberOrNull) === false) {
+ if (canBeCastAsNumberOrNull(probableNumberOrNull) === false) {
throw new Error('Cannot cast to number or null');
}
diff --git a/packages/twenty-front/src/utils/format/number.ts b/packages/twenty-front/src/utils/format/number.ts
index 4937372d0cbd..a36cb6fffad8 100644
--- a/packages/twenty-front/src/utils/format/number.ts
+++ b/packages/twenty-front/src/utils/format/number.ts
@@ -1,2 +1,8 @@
-export const formatNumber = (value: number): string =>
- value.toLocaleString('en-US');
+export const DEFAULT_DECIMAL_VALUE = 0;
+
+export const formatNumber = (value: number, decimals?: number): string => {
+ return value.toLocaleString('en-US', {
+ minimumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
+ maximumFractionDigits: decimals ?? DEFAULT_DECIMAL_VALUE,
+ });
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts
new file mode 100644
index 000000000000..81ede4ec4faa
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata-validation.service.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@nestjs/common';
+
+import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
+
+import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+import {
+ FieldMetadataException,
+ FieldMetadataExceptionCode,
+} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
+
+@Injectable()
+export class FieldMetadataValidationService<
+ T extends FieldMetadataType | 'default' = 'default',
+> {
+ constructor() {}
+
+ validateSettingsOrThrow({
+ fieldType,
+ settings,
+ }: {
+ fieldType: FieldMetadataType;
+ settings: FieldMetadataSettings;
+ }) {
+ switch (fieldType) {
+ case FieldMetadataType.NUMBER:
+ this.validateNumberSettings(settings);
+ break;
+ default:
+ break;
+ }
+ }
+
+ private validateNumberSettings(settings: FieldMetadataSettings) {
+ if ('decimals' in settings) {
+ const { decimals } = settings;
+
+ if (
+ decimals !== undefined &&
+ (decimals < 0 || !Number.isInteger(decimals))
+ ) {
+ throw new FieldMetadataException(
+ `Decimals value "${decimals}" must be a positive integer`,
+ FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
index 377575ba9bfd..b6cb8e8ed1d7 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts
@@ -12,6 +12,7 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
+import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
@@ -44,7 +45,11 @@ import { UpdateFieldInput } from './dtos/update-field.input';
TypeORMModule,
ActorModule,
],
- services: [IsFieldMetadataDefaultValue, FieldMetadataService],
+ services: [
+ IsFieldMetadataDefaultValue,
+ FieldMetadataService,
+ FieldMetadataValidationService,
+ ],
resolvers: [
{
EntityClass: FieldMetadataEntity,
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
index ac2c5b1d438d..019cfcea7bdf 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts
@@ -56,6 +56,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
+import { FieldMetadataValidationService } from './field-metadata-validation.service';
import {
FieldMetadataEntity,
FieldMetadataType,
@@ -82,6 +83,7 @@ export class FieldMetadataService extends TypeOrmQueryService(
+ fieldMetadataInput.type,
fieldMetadataInput,
objectMetadata,
);
@@ -391,6 +394,7 @@ export class FieldMetadataService extends TypeOrmQueryService(
+ existingFieldMetadata.type,
fieldMetadataInput,
objectMetadata,
);
@@ -707,7 +711,11 @@ export class FieldMetadataService extends TypeOrmQueryService(fieldMetadataInput: T, objectMetadata: ObjectMetadataEntity): T {
+ >(
+ fieldMetadataType: FieldMetadataType,
+ fieldMetadataInput: T,
+ objectMetadata: ObjectMetadataEntity,
+ ): T {
if (fieldMetadataInput.name) {
try {
validateFieldNameValidityOrThrow(fieldMetadataInput.name);
@@ -748,6 +756,13 @@ export class FieldMetadataService extends TypeOrmQueryService
Date: Fri, 4 Oct 2024 11:41:05 +0200
Subject: [PATCH 04/16] Add Skeleton loading for side panel (#7394)
This PR was created by [GitStart](https://gitstart.com/) to address the
requirements from this ticket:
[TWNTY-7112](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-7112).
---
### Description
- To test you can use `await new Promise(r => setTimeout(r, 5000));`
line 74 of \`openCreateActivityDrawer.ts\`
- We added a recoil state to define the loading status
- Design points to note:
1 - We did not change the chip component styles because would be
unrelated to the issue can you confirm if you still need this change?

2- In Figma, the loading state shows the Chip rendering an initial name
before showing the loaded name, currently, we are rendering the correct
name while loading, the change that makes this possible is below

if we set it as null, the initial name would appear, but also the
previous data in the state would affect the UI, passing the
`activityObjectNameSingular` data allows us to clear the previous data,
and make the Chip instantly updated, let us know if this behavior is
fine, or if you still want an initial name to be rendered while is
loading.
3 - Currently, the loading state of the tabs does not affect the
selected tab (auto-defined by the component) should we change this logic
for all Tabs used in the app, or make this behavior optional by using
props?

### Demo
### Refs
#7112
---------
Co-authored-by: gitstart-twenty
Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com>
Co-authored-by: Marie Stoppa
---
.../components/LeftPanelSkeletonLoader.tsx | 6 +-
...ainNavigationDrawerItemsSkeletonLoader.tsx | 14 ++-
.../components/RightPanelSkeletonLoader.tsx | 8 +-
.../activities/components/SkeletonLoader.tsx | 47 +++++++--
.../hooks/useOpenCreateActivityDrawer.ts | 13 ++-
.../components/FavoritesSkeletonLoader.tsx | 18 +++-
...onForObjectMetadataItemsSkeletonLoader.tsx | 16 ++-
...BoardColumnCardContainerSkeletonLoader.tsx | 18 +++-
.../RecordInlineCellSkeletonLoader.tsx | 8 +-
.../components/PropertyBoxSkeletonLoader.tsx | 13 ++-
.../components/RightDrawerRecord.tsx | 9 --
.../states/isNewViewableRecordLoading.ts | 6 ++
.../components/RecordShowContainer.tsx | 99 ++++++++++---------
.../RecordTableCellSkeletonLoader.tsx | 8 +-
.../SupportButtonSkeletonLoader.tsx | 5 +-
.../skeletons/DropdownMenuSkeletonItem.tsx | 5 +-
.../components/RightDrawerTopBar.tsx | 6 ++
.../components/ShowPageActivityContainer.tsx | 10 +-
.../components/ShowPageRightContainer.tsx | 7 +-
.../components/ShowPageSummaryCard.tsx | 5 +-
.../ShowPageSummaryCardSkeletonLoader.tsx | 36 +++++++
...gationDrawerSectionTitleSkeletonLoader.tsx | 8 +-
.../components/ViewBarSkeletonLoader.tsx | 5 +-
.../src/pages/auth/PasswordReset.tsx | 3 +-
24 files changed, 273 insertions(+), 100 deletions(-)
create mode 100644 packages/twenty-front/src/modules/object-record/record-right-drawer/states/isNewViewableRecordLoading.ts
create mode 100644 packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader.tsx
diff --git a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx
index 38803c9efecf..c8de2f64c46a 100644
--- a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx
+++ b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx
@@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ANIMATION, BACKGROUND_LIGHT, GRAY_SCALE } from 'twenty-ui';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { DESKTOP_NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { MainNavigationDrawerItemsSkeletonLoader } from '~/loading/components/MainNavigationDrawerItemsSkeletonLoader';
@@ -67,7 +68,10 @@ export const LeftPanelSkeletonLoader = () => {
highlightColor={BACKGROUND_LIGHT.transparent.lighter}
borderRadius={4}
>
-
+
diff --git a/packages/twenty-front/src/loading/components/MainNavigationDrawerItemsSkeletonLoader.tsx b/packages/twenty-front/src/loading/components/MainNavigationDrawerItemsSkeletonLoader.tsx
index 2d660dfda236..bfa360a98070 100644
--- a/packages/twenty-front/src/loading/components/MainNavigationDrawerItemsSkeletonLoader.tsx
+++ b/packages/twenty-front/src/loading/components/MainNavigationDrawerItemsSkeletonLoader.tsx
@@ -1,3 +1,4 @@
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { BACKGROUND_LIGHT, GRAY_SCALE } from 'twenty-ui';
@@ -26,9 +27,18 @@ export const MainNavigationDrawerItemsSkeletonLoader = ({
highlightColor={BACKGROUND_LIGHT.transparent.lighter}
borderRadius={4}
>
- {title && }
+ {title && (
+
+ )}
{Array.from({ length }).map((_, index) => (
-
+
))}
diff --git a/packages/twenty-front/src/loading/components/RightPanelSkeletonLoader.tsx b/packages/twenty-front/src/loading/components/RightPanelSkeletonLoader.tsx
index 1c47a6cdcb6c..9e98594369ab 100644
--- a/packages/twenty-front/src/loading/components/RightPanelSkeletonLoader.tsx
+++ b/packages/twenty-front/src/loading/components/RightPanelSkeletonLoader.tsx
@@ -1,3 +1,4 @@
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import {
@@ -60,7 +61,10 @@ const StyledSkeletonHeaderLoader = () => {
highlightColor={BACKGROUND_LIGHT.transparent.lighter}
borderRadius={4}
>
-
+
);
@@ -73,7 +77,7 @@ const StyledSkeletonAddLoader = () => {
highlightColor={BACKGROUND_LIGHT.transparent.lighter}
borderRadius={4}
>
-
+
);
};
diff --git a/packages/twenty-front/src/modules/activities/components/SkeletonLoader.tsx b/packages/twenty-front/src/modules/activities/components/SkeletonLoader.tsx
index 21478ed73c32..0b65ab1143c3 100644
--- a/packages/twenty-front/src/modules/activities/components/SkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/activities/components/SkeletonLoader.tsx
@@ -1,6 +1,6 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
align-items: center;
@@ -25,6 +25,21 @@ const StyledSkeletonSubSectionContent = styled.div`
justify-content: center;
`;
+export const SKELETON_LOADER_HEIGHT_SIZES = {
+ standard: {
+ xs: 13,
+ s: 16,
+ m: 24,
+ l: 32,
+ xl: 40,
+ },
+ columns: {
+ s: 84,
+ m: 120,
+ xxl: 542,
+ },
+};
+
const SkeletonColumnLoader = ({ height }: { height: number }) => {
const theme = useTheme();
return (
@@ -55,15 +70,35 @@ export const SkeletonLoader = ({
borderRadius={4}
>
-
+
{withSubSections &&
skeletonItems.map(({ id }, index) => (
-
+
-
-
- {index === 1 && }
+
+
+ {index === 1 && (
+
+ )}
))}
diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts
index 400c1f398a95..c35d27837121 100644
--- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts
+++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts
@@ -15,6 +15,7 @@ import { Task } from '@/activities/types/Task';
import { TaskTarget } from '@/activities/types/TaskTarget';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
+import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { ActivityTargetableObject } from '../types/ActivityTargetableEntity';
@@ -52,7 +53,9 @@ export const useOpenCreateActivityDrawer = ({
const setViewableRecordNameSingular = useSetRecoilState(
viewableRecordNameSingularState,
);
-
+ const setIsNewViewableRecordLoading = useSetRecoilState(
+ isNewViewableRecordLoadingState,
+ );
const setIsUpsertingActivityInDB = useSetRecoilState(
isUpsertingActivityInDBState,
);
@@ -64,6 +67,11 @@ export const useOpenCreateActivityDrawer = ({
targetableObjects: ActivityTargetableObject[];
customAssignee?: WorkspaceMember;
}) => {
+ openRightDrawer(RightDrawerPages.ViewRecord);
+ setIsNewViewableRecordLoading(true);
+ setViewableRecordId(null);
+ setViewableRecordNameSingular(activityObjectNameSingular);
+
const activity = await createOneActivity({
assigneeId: customAssignee?.id,
});
@@ -101,10 +109,9 @@ export const useOpenCreateActivityDrawer = ({
setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false });
setViewableRecordId(activity.id);
- setViewableRecordNameSingular(activityObjectNameSingular);
- openRightDrawer(RightDrawerPages.ViewRecord);
setIsUpsertingActivityInDB(false);
+ setIsNewViewableRecordLoading(false);
};
return openCreateActivityDrawer;
diff --git a/packages/twenty-front/src/modules/favorites/components/FavoritesSkeletonLoader.tsx b/packages/twenty-front/src/modules/favorites/components/FavoritesSkeletonLoader.tsx
index 3822a12bc9b6..5e5d1ea7f8ec 100644
--- a/packages/twenty-front/src/modules/favorites/components/FavoritesSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/favorites/components/FavoritesSkeletonLoader.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
display: flex;
@@ -25,10 +26,19 @@ export const FavoritesSkeletonLoader = () => {
borderRadius={4}
>
-
+
-
-
+
+
diff --git a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader.tsx
index 70e00d717330..70b965b1827f 100644
--- a/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/object-metadata/components/NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader.tsx
@@ -1,3 +1,4 @@
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
@@ -20,9 +21,18 @@ export const NavigationDrawerSectionForObjectMetadataItemsSkeletonLoader: React.
borderRadius={4}
>
-
-
-
+
+
+
);
diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx
index 4aeafd96f1fd..afaf8e9d78f4 100644
--- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnCardContainerSkeletonLoader.tsx
@@ -1,7 +1,8 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import {
StyledBoardCardBody,
StyledBoardCardHeader,
@@ -43,7 +44,10 @@ export const RecordBoardColumnCardContainerSkeletonLoader = ({
>
-
+
@@ -51,8 +55,14 @@ export const RecordBoardColumnCardContainerSkeletonLoader = ({
skeletonItems.map(({ id }) => (
-
-
+
+
))}
diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx
index 2e673478dbd5..bd912d7b55bd 100644
--- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useTheme } from '@emotion/react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { StyledSkeletonDiv } from './RecordInlineCellContainer';
export const RecordInlineCellSkeletonLoader = () => {
@@ -13,7 +14,10 @@ export const RecordInlineCellSkeletonLoader = () => {
borderRadius={4}
>
-
+
);
diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader.tsx
index 38ee35d74787..88a499952c1f 100644
--- a/packages/twenty-front/src/modules/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonDiv = styled.div`
align-items: center;
@@ -22,8 +23,14 @@ export const PropertyBoxSkeletonLoader = () => {
>
{skeletonItems.map(({ id }) => (
-
-
+
+
))}
diff --git a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx
index e68ce3883ef0..6a76206e6e75 100644
--- a/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-right-drawer/components/RightDrawerRecord.tsx
@@ -19,15 +19,6 @@ export const RightDrawerRecord = () => {
viewableRecordNameSingularState,
);
const viewableRecordId = useRecoilValue(viewableRecordIdState);
-
- if (!viewableRecordNameSingular) {
- throw new Error(`Object name is not defined`);
- }
-
- if (!viewableRecordId) {
- throw new Error(`Record id is not defined`);
- }
-
const { objectNameSingular, objectRecordId } = useRecordShowPage(
viewableRecordNameSingular ?? '',
viewableRecordId ?? '',
diff --git a/packages/twenty-front/src/modules/object-record/record-right-drawer/states/isNewViewableRecordLoading.ts b/packages/twenty-front/src/modules/object-record/record-right-drawer/states/isNewViewableRecordLoading.ts
new file mode 100644
index 000000000000..904677204cc6
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-right-drawer/states/isNewViewableRecordLoading.ts
@@ -0,0 +1,6 @@
+import { createState } from 'twenty-ui';
+
+export const isNewViewableRecordLoadingState = createState({
+ key: 'activities/is-new-viewable-record-loading',
+ defaultValue: false,
+});
diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
index 5f23472fe180..e72df975f22f 100644
--- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
@@ -21,6 +21,7 @@ import { RecordInlineCell } from '@/object-record/record-inline-cell/components/
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
+import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { RecordDetailDuplicatesSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection';
import { RecordDetailRelationSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSection';
import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState';
@@ -33,6 +34,7 @@ import { ShowPageContainer } from '@/ui/layout/page/ShowPageContainer';
import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer';
import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer';
import { ShowPageSummaryCard } from '@/ui/layout/show-page/components/ShowPageSummaryCard';
+import { ShowPageSummaryCardSkeletonLoader } from '@/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import {
FieldMetadataType,
@@ -80,7 +82,9 @@ export const RecordShowContainer = ({
recordId: objectRecordId,
}),
);
-
+ const isNewViewableRecordLoading = useRecoilValue(
+ isNewViewableRecordLoadingState,
+ );
const [uploadImage] = useUploadImageMutation();
const { updateOneRecord } = useUpdateOneRecord({ objectNameSingular });
@@ -162,53 +166,56 @@ export const RecordShowContainer = ({
const isReadOnly = objectMetadataItem.isRemote;
const isMobile = useIsMobile() || isInRightDrawer;
const isPrefetchLoading = useIsPrefetchLoading();
+ const isNewRightDrawerItemLoading =
+ isInRightDrawer && isNewViewableRecordLoading;
- const summaryCard = isDefined(recordFromStore) ? (
-
-
-
- }
- avatarType={recordIdentifier?.avatarType ?? 'rounded'}
- onUploadPicture={
- objectNameSingular === 'person' ? onUploadPicture : undefined
- }
- />
- ) : (
- <>>
- );
+ useUpdateRecord: useUpdateOneObjectRecordMutation,
+ hotkeyScope: InlineCellHotkeyScope.InlineCell,
+ isCentered: true,
+ }}
+ >
+
+
+ }
+ avatarType={recordIdentifier?.avatarType ?? 'rounded'}
+ onUploadPicture={
+ objectNameSingular === 'person' ? onUploadPicture : undefined
+ }
+ />
+ ) : (
+
+ );
const fieldsBox = (
<>
diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader.tsx
index b010bdaecd6d..f62018d33388 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSkeletonLoader.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
padding-left: ${({ theme }) => theme.spacing(2)};
@@ -15,7 +16,10 @@ const StyledRecordTableCellLoader = ({ width }: { width?: number }) => {
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
-
+
);
};
diff --git a/packages/twenty-front/src/modules/support/components/SupportButtonSkeletonLoader.tsx b/packages/twenty-front/src/modules/support/components/SupportButtonSkeletonLoader.tsx
index 86d4d1b93231..78440fe021d2 100644
--- a/packages/twenty-front/src/modules/support/components/SupportButtonSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/support/components/SupportButtonSkeletonLoader.tsx
@@ -1,5 +1,6 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
export const SupportButtonSkeletonLoader = () => {
const theme = useTheme();
@@ -9,7 +10,7 @@ export const SupportButtonSkeletonLoader = () => {
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
-
+
);
};
diff --git a/packages/twenty-front/src/modules/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem.tsx b/packages/twenty-front/src/modules/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem.tsx
index f4853bc48c69..820831f7c77b 100644
--- a/packages/twenty-front/src/modules/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem.tsx
+++ b/packages/twenty-front/src/modules/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledDropdownMenuSkeletonContainer = styled.div`
--horizontal-padding: ${({ theme }) => theme.spacing(1)};
@@ -21,7 +22,7 @@ export const DropdownMenuSkeletonItem = () => {
baseColor={theme.background.quaternary}
highlightColor={theme.background.secondary}
>
-
+
);
diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx
index 9176af20340a..b80b943e1730 100644
--- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawerTopBar.tsx
@@ -5,6 +5,7 @@ import { Chip, ChipAccent, ChipSize, useIcons } from 'twenty-ui';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage';
+import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { RightDrawerTopBarCloseButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarCloseButton';
@@ -66,6 +67,10 @@ export const RightDrawerTopBar = () => {
viewableRecordNameSingularState,
);
+ const isNewViewableRecordLoading = useRecoilValue(
+ isNewViewableRecordLoadingState,
+ );
+
const viewableRecordId = useRecoilValue(viewableRecordIdState);
const { objectMetadataItem } = useObjectMetadataItem({
@@ -95,6 +100,7 @@ export const RightDrawerTopBar = () => {
>
{!isRightDrawerMinimized && (
}
size={ChipSize.Large}
diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx
index a69f54528cba..2e38456c4fb9 100644
--- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageActivityContainer.tsx
@@ -1,8 +1,10 @@
import { RichTextEditor } from '@/activities/components/RichTextEditor';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
+import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import styled from '@emotion/styled';
+import { useRecoilValue } from 'recoil';
const StyledShowPageActivityContainer = styled.div`
margin-top: ${({ theme }) => theme.spacing(6)};
@@ -16,7 +18,11 @@ export const ShowPageActivityContainer = ({
'targetObjectNameSingular' | 'id'
>;
}) => {
- return (
+ const isNewViewableRecordLoading = useRecoilValue(
+ isNewViewableRecordLoadingState,
+ );
+
+ return !isNewViewableRecordLoading ? (
+ ) : (
+ <>>
);
};
diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx
index c06129296187..dab0a2104e12 100644
--- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageRightContainer.tsx
@@ -7,6 +7,7 @@ import { TimelineActivities } from '@/activities/timelineActivities/components/T
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
+import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { Button } from '@/ui/input/button/components/Button';
@@ -135,6 +136,10 @@ export const ShowPageRightContainer = ({
const isMobile = useIsMobile();
+ const isNewViewableRecordLoading = useRecoilValue(
+ isNewViewableRecordLoadingState,
+ );
+
const tabs = [
{
id: 'richText',
@@ -272,7 +277,7 @@ export const ShowPageRightContainer = ({
diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx
index 9627be5d7e61..dca1b2cea957 100644
--- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx
@@ -1,3 +1,4 @@
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ChangeEvent, ReactNode, useRef } from 'react';
@@ -88,9 +89,9 @@ const StyledShowPageSummaryCardSkeletonLoader = () => {
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
-
+
-
+
);
diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader.tsx
new file mode 100644
index 000000000000..51b15f506c67
--- /dev/null
+++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCardSkeletonLoader.tsx
@@ -0,0 +1,36 @@
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+
+const StyledContainer = styled.div`
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(4)};
+ height: ${({ theme }) => theme.spacing(19)};
+ margin: ${({ theme }) => theme.spacing(4)};
+`;
+
+const StyledRectangularSkeleton = styled(Skeleton)`
+ height: ${({ theme }) => theme.spacing(4)};
+ width: ${({ theme }) => theme.spacing(24)};
+ margin: ${({ theme }) => theme.spacing(1)};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+`;
+
+export const ShowPageSummaryCardSkeletonLoader = () => {
+ const theme = useTheme();
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader.tsx
index fcd3ffe20eaa..5391ea3e320d 100644
--- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader.tsx
@@ -1,6 +1,7 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonTitle = styled.div`
margin-bottom: ${(props) => props.theme.spacing(2)};
@@ -16,7 +17,10 @@ export const NavigationDrawerSectionTitleSkeletonLoader = () => {
borderRadius={4}
>
-
+
);
diff --git a/packages/twenty-front/src/modules/views/components/ViewBarSkeletonLoader.tsx b/packages/twenty-front/src/modules/views/components/ViewBarSkeletonLoader.tsx
index a7d0c1b247c1..a82565afa813 100644
--- a/packages/twenty-front/src/modules/views/components/ViewBarSkeletonLoader.tsx
+++ b/packages/twenty-front/src/modules/views/components/ViewBarSkeletonLoader.tsx
@@ -1,5 +1,6 @@
-import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
export const ViewBarSkeletonLoader = () => {
const theme = useTheme();
@@ -9,7 +10,7 @@ export const ViewBarSkeletonLoader = () => {
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
-
+
);
};
diff --git a/packages/twenty-front/src/pages/auth/PasswordReset.tsx b/packages/twenty-front/src/pages/auth/PasswordReset.tsx
index 79ea7074021d..11cf5d016b42 100644
--- a/packages/twenty-front/src/pages/auth/PasswordReset.tsx
+++ b/packages/twenty-front/src/pages/auth/PasswordReset.tsx
@@ -10,6 +10,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useSetRecoilState } from 'recoil';
import { z } from 'zod';
+import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { Logo } from '@/auth/components/Logo';
import { Title } from '@/auth/components/Title';
import { useAuth } from '@/auth/hooks/useAuth';
@@ -174,7 +175,7 @@ export const PasswordReset = () => {
highlightColor={theme.background.secondary}
>
Date: Fri, 4 Oct 2024 11:58:33 +0200
Subject: [PATCH 05/16] Refactor graphql query runner and add mutation
resolvers (#7418)
Fixes https://github.com/twentyhq/twenty/issues/6859
This PR adds all the remaining resolvers for
- updateOne/updateMany
- createOne/createMany
- deleteOne/deleteMany
- destroyOne
- restoreMany
Also
- refactored the graphql-query-runner to be able to add other resolvers
without too much boilerplate.
- add missing events that were not sent anymore as well as webhooks
- make resolver injectable so they can inject other services as well
- use objectMetadataMap from cache instead of computing it multiple time
- various fixes (mutation not correctly parsing JSON, relationHelper
fetching data with empty ids set, ...)
Next steps:
- Wrapping query builder to handle DB events properly
- Move webhook emitters to db event listener
- Add pagination where it's missing (findDuplicates, nested relations,
etc...)
---
.../1726848397026-addTypeOrmMetadata.ts | 0
.../migrations/1725893697807-addIndexType.ts | 8 +-
.../graphql-query-resolver.factory.ts | 51 ++
.../graphql-query-filter-condition.parser.ts | 2 +-
.../graphql-query-filter-field.parser.ts | 3 -
.../graphql-query.parser.ts | 14 +-
.../graphql-query-runner.module.ts | 29 +-
.../graphql-query-runner.service.ts | 533 +++++++++---------
...t-records-to-graphql-connection.helper.ts} | 2 +-
.../process-nested-relations.helper.ts | 260 +++++----
.../interfaces/resolver-service.interface.ts | 12 +
...phql-query-create-many-resolver.service.ts | 103 ++--
...phql-query-destroy-one-resolver.service.ts | 57 +-
...-query-find-duplicates-resolver.service.ts | 214 +++++++
...raphql-query-find-many-resolver.service.ts | 128 ++---
...graphql-query-find-one-resolver.service.ts | 77 +--
.../graphql-query-search-resolver.service.ts | 84 ++-
...phql-query-update-many-resolver.service.ts | 116 ++++
...aphql-query-update-one-resolver.service.ts | 123 ++++
.../services/api-event-emitter.service.ts | 137 +++++
.../utils/cursors.util.ts | 23 +
.../query-runner-option.interface.ts | 6 +
.../types/workspace-query-hook.type.ts | 5 +-
.../factories/create-many-resolver.factory.ts | 5 +-
.../factories/create-one-resolver.factory.ts | 13 +-
.../factories/delete-many-resolver.factory.ts | 24 +-
.../factories/delete-one-resolver.factory.ts | 24 +-
.../destroy-many-resolver.factory.ts | 12 +-
.../factories/destroy-one-resolver.factory.ts | 9 +-
.../find-duplicates-resolver.factory.ts | 30 +-
.../factories/find-many-resolver.factory.ts | 5 +-
.../factories/find-one-resolver.factory.ts | 5 +-
.../restore-many-resolver.factory.ts | 30 +-
.../factories/search-resolver-factory.ts | 9 +-
.../factories/update-many-resolver.factory.ts | 24 +-
.../factories/update-one-resolver.factory.ts | 24 +-
.../workspace-resolver.factory.ts | 10 +-
...kspace-schema-builder-context.interface.ts | 8 +-
.../api/graphql/workspace-schema.factory.ts | 1 +
.../duplicate/duplicate.service.ts | 80 +--
.../index-metadata/index-metadata.entity.ts | 2 +-
.../twenty-orm/utils/format-data.util.ts | 91 +--
.../twenty-orm/utils/format-result.util.ts | 24 +
43 files changed, 1669 insertions(+), 748 deletions(-)
rename packages/twenty-server/src/database/typeorm/{metadata => core}/migrations/1726848397026-addTypeOrmMetadata.ts (100%)
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts
rename packages/twenty-server/src/engine/api/graphql/graphql-query-runner/{orm-mappers/object-records-to-graphql-connection.mapper.ts => helpers/object-records-to-graphql-connection.helper.ts} (99%)
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts
diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1726848397026-addTypeOrmMetadata.ts
similarity index 100%
rename from packages/twenty-server/src/database/typeorm/metadata/migrations/1726848397026-addTypeOrmMetadata.ts
rename to packages/twenty-server/src/database/typeorm/core/migrations/1726848397026-addTypeOrmMetadata.ts
diff --git a/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts
index 41edac1a5990..59a1828627ea 100644
--- a/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts
+++ b/packages/twenty-server/src/database/typeorm/metadata/migrations/1725893697807-addIndexType.ts
@@ -5,12 +5,12 @@ export class AddIndexType1725893697807 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise {
await queryRunner.query(
- `CREATE TYPE metadata."indextype_enum" AS ENUM ('BTREE', 'GIN')`,
+ `CREATE TYPE "metadata"."indexMetadata_indextype_enum" AS ENUM('BTREE', 'GIN')`,
);
await queryRunner.query(`
ALTER TABLE metadata."indexMetadata"
- ADD COLUMN "indexType" metadata."indextype_enum" NOT NULL DEFAULT 'BTREE';
+ ADD COLUMN "indexType" metadata."indexMetadata_indextype_enum" NOT NULL DEFAULT 'BTREE';
`);
}
@@ -19,6 +19,8 @@ export class AddIndexType1725893697807 implements MigrationInterface {
ALTER TABLE metadata."indexMetadata" DROP COLUMN "indexType"
`);
- await queryRunner.query(`DROP TYPE metadata."indextype_enum"`);
+ await queryRunner.query(
+ `DROP TYPE metadata."indexMetadata_indextype_enum"`,
+ );
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts
new file mode 100644
index 000000000000..ca2506d18aa9
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory.ts
@@ -0,0 +1,51 @@
+import { Injectable } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
+import {
+ ResolverArgs,
+ WorkspaceResolverBuilderMethodNames,
+} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+
+import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
+import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
+import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
+import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
+import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
+import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
+import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
+import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service';
+
+@Injectable()
+export class GraphqlQueryResolverFactory {
+ constructor(private moduleRef: ModuleRef) {}
+
+ public getResolver(
+ operationName: WorkspaceResolverBuilderMethodNames,
+ ): ResolverService {
+ switch (operationName) {
+ case 'findOne':
+ return this.moduleRef.get(GraphqlQueryFindOneResolverService);
+ case 'findMany':
+ return this.moduleRef.get(GraphqlQueryFindManyResolverService);
+ case 'findDuplicates':
+ return this.moduleRef.get(GraphqlQueryFindDuplicatesResolverService);
+ case 'search':
+ return this.moduleRef.get(GraphqlQuerySearchResolverService);
+ case 'createOne':
+ case 'createMany':
+ return this.moduleRef.get(GraphqlQueryCreateManyResolverService);
+ case 'destroyOne':
+ return this.moduleRef.get(GraphqlQueryDestroyOneResolverService);
+ case 'updateOne':
+ case 'deleteOne':
+ return this.moduleRef.get(GraphqlQueryUpdateOneResolverService);
+ case 'updateMany':
+ case 'deleteMany':
+ case 'restoreMany':
+ return this.moduleRef.get(GraphqlQueryUpdateManyResolverService);
+ default:
+ throw new Error(`Unsupported operation: ${operationName}`);
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts
index 093364db8479..21f9bdbdccb0 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser.ts
@@ -25,7 +25,7 @@ export class GraphqlQueryFilterConditionParser {
public parse(
queryBuilder: SelectQueryBuilder,
objectNameSingular: string,
- filter: RecordFilter,
+ filter: Partial,
): SelectQueryBuilder {
if (!filter || Object.keys(filter).length === 0) {
return queryBuilder;
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
index fe37c4d445da..920fa01c56d6 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts
@@ -58,7 +58,6 @@ export class GraphqlQueryFilterFieldParser {
}
const { sql, params } = this.computeWhereConditionParts(
- fieldMetadata,
operator,
objectNameSingular,
key,
@@ -73,7 +72,6 @@ export class GraphqlQueryFilterFieldParser {
}
private computeWhereConditionParts(
- fieldMetadata: FieldMetadataInterface,
operator: string,
objectNameSingular: string,
key: string,
@@ -185,7 +183,6 @@ export class GraphqlQueryFilterFieldParser {
);
const { sql, params } = this.computeWhereConditionParts(
- fieldMetadata,
operator,
objectNameSingular,
fullFieldName,
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
index d83667211bd0..0aa047fc31f7 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts
@@ -9,7 +9,6 @@ import {
RecordFilter,
RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
-import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser';
import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
@@ -17,6 +16,7 @@ import { GraphqlQuerySelectedFieldsParser } from 'src/engine/api/graphql/graphql
import {
FieldMetadataMap,
ObjectMetadataMap,
+ ObjectMetadataMapItem,
} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
export class GraphqlQueryParser {
@@ -39,10 +39,10 @@ export class GraphqlQueryParser {
);
}
- applyFilterToBuilder(
+ public applyFilterToBuilder(
queryBuilder: SelectQueryBuilder,
objectNameSingular: string,
- recordFilter: RecordFilter,
+ recordFilter: Partial,
): SelectQueryBuilder {
return this.filterConditionParser.parse(
queryBuilder,
@@ -51,7 +51,7 @@ export class GraphqlQueryParser {
);
}
- applyDeletedAtToBuilder(
+ public applyDeletedAtToBuilder(
queryBuilder: SelectQueryBuilder,
recordFilter: RecordFilter,
): SelectQueryBuilder {
@@ -88,7 +88,7 @@ export class GraphqlQueryParser {
return false;
};
- applyOrderToBuilder(
+ public applyOrderToBuilder(
queryBuilder: SelectQueryBuilder,
orderBy: RecordOrderBy,
objectNameSingular: string,
@@ -103,8 +103,8 @@ export class GraphqlQueryParser {
return queryBuilder.orderBy(parsedOrderBys as OrderByCondition);
}
- parseSelectedFields(
- parentObjectMetadata: ObjectMetadataInterface,
+ public parseSelectedFields(
+ parentObjectMetadata: ObjectMetadataMapItem,
graphqlSelectedFields: Partial>,
): { select: Record; relations: Record } {
const parentFields =
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
index 28cb362ca3f0..8c689ff8a979 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
@@ -1,16 +1,43 @@
import { Module } from '@nestjs/common';
+import { GraphqlQueryResolverFactory } from 'src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory';
import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
+import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
+import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
+import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
+import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
+import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
+import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
+import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
+import { GraphqlQueryUpdateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service';
+import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { WorkspaceQueryHookModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
+
+const graphqlQueryResolvers = [
+ GraphqlQueryFindOneResolverService,
+ GraphqlQueryFindManyResolverService,
+ GraphqlQueryFindDuplicatesResolverService,
+ GraphqlQueryCreateManyResolverService,
+ GraphqlQueryDestroyOneResolverService,
+ GraphqlQueryUpdateOneResolverService,
+ GraphqlQueryUpdateManyResolverService,
+ GraphqlQuerySearchResolverService,
+];
+
@Module({
imports: [
WorkspaceQueryHookModule,
WorkspaceQueryRunnerModule,
FeatureFlagModule,
],
- providers: [GraphqlQueryRunnerService],
+ providers: [
+ GraphqlQueryRunnerService,
+ GraphqlQueryResolverFactory,
+ ApiEventEmitterService,
+ ...graphqlQueryResolvers,
+ ],
exports: [GraphqlQueryRunnerService],
})
export class GraphqlQueryRunnerModule {}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts
index 8c866695b6ad..4e3475927ecd 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service.ts
@@ -6,285 +6,377 @@ import {
RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
+import { IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import {
CreateManyResolverArgs,
CreateOneResolverArgs,
+ DeleteManyResolverArgs,
+ DeleteOneResolverArgs,
DestroyOneResolverArgs,
+ FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
+ ResolverArgs,
ResolverArgsType,
+ RestoreManyResolverArgs,
SearchResolverArgs,
+ UpdateManyResolverArgs,
+ UpdateOneResolverArgs,
+ WorkspaceResolverBuilderMethodNames,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
-import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
-import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service';
-import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service';
-import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
-import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
-import { GraphqlQuerySearchResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service';
+import { GraphqlQueryResolverFactory } from 'src/engine/api/graphql/graphql-query-runner/factories/graphql-query-resolver.factory';
+import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { QueryRunnerArgsFactory } from 'src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory';
import {
CallWebhookJobsJob,
CallWebhookJobsJobData,
CallWebhookJobsJobOperation,
} from 'src/engine/api/graphql/workspace-query-runner/jobs/call-webhook-jobs.job';
-import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.service';
-import {
- WorkspaceQueryRunnerException,
- WorkspaceQueryRunnerExceptionCode,
-} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
-import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
-import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event';
-import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event';
-import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { LogExecutionTime } from 'src/engine/decorators/observability/log-execution-time.decorator';
-import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
-import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
-import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
+import { capitalize } from 'src/utils/capitalize';
@Injectable()
export class GraphqlQueryRunnerService {
constructor(
- private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
- private readonly featureFlagService: FeatureFlagService,
private readonly workspaceQueryHookService: WorkspaceQueryHookService,
private readonly queryRunnerArgsFactory: QueryRunnerArgsFactory,
- private readonly workspaceEventEmitter: WorkspaceEventEmitter,
@InjectMessageQueue(MessageQueue.webhookQueue)
private readonly messageQueueService: MessageQueueService,
+ private readonly graphqlQueryResolverFactory: GraphqlQueryResolverFactory,
+ private readonly apiEventEmitterService: ApiEventEmitterService,
) {}
+ /** QUERIES */
+
@LogExecutionTime()
- async findOne<
- ObjectRecord extends IRecord = IRecord,
- Filter extends RecordFilter = RecordFilter,
- >(
+ async findOne(
args: FindOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const graphqlQueryFindOneResolverService =
- new GraphqlQueryFindOneResolverService(this.twentyORMGlobalManager);
-
- const { authContext, objectMetadataItem } = options;
-
- if (!args.filter || Object.keys(args.filter).length === 0) {
- throw new WorkspaceQueryRunnerException(
- 'Missing filter argument',
- WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
- );
- }
-
- const hookedArgs =
- await this.workspaceQueryHookService.executePreQueryHooks(
- authContext,
- objectMetadataItem.nameSingular,
- 'findOne',
- args,
- );
-
- const computedArgs = (await this.queryRunnerArgsFactory.create(
- hookedArgs,
+ ): Promise {
+ return this.executeQuery, ObjectRecord>(
+ 'findOne',
+ args,
options,
- ResolverArgsType.FindOne,
- )) as FindOneResolverArgs;
-
- return graphqlQueryFindOneResolverService.findOne(computedArgs, options);
+ );
}
@LogExecutionTime()
async findMany<
- ObjectRecord extends IRecord = IRecord,
- Filter extends RecordFilter = RecordFilter,
- OrderBy extends RecordOrderBy = RecordOrderBy,
+ ObjectRecord extends IRecord,
+ Filter extends RecordFilter,
+ OrderBy extends RecordOrderBy,
>(
args: FindManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
+ ): Promise>> {
+ return this.executeQuery<
+ FindManyResolverArgs,
+ IConnection>
+ >('findMany', args, options);
+ }
+
+ @LogExecutionTime()
+ async findDuplicates(
+ args: FindDuplicatesResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise[]> {
+ return this.executeQuery<
+ FindDuplicatesResolverArgs>,
+ IConnection[]
+ >('findDuplicates', args, options);
+ }
+
+ @LogExecutionTime()
+ async search(
+ args: SearchResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
): Promise> {
- const graphqlQueryFindManyResolverService =
- new GraphqlQueryFindManyResolverService(this.twentyORMGlobalManager);
+ return this.executeQuery>(
+ 'search',
+ args,
+ options,
+ );
+ }
- const { authContext, objectMetadataItem } = options;
+ /** MUTATIONS */
- const hookedArgs =
- await this.workspaceQueryHookService.executePreQueryHooks(
- authContext,
- objectMetadataItem.nameSingular,
- 'findMany',
- args,
+ @LogExecutionTime()
+ async createOne(
+ args: CreateOneResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const results = await this.executeQuery<
+ CreateManyResolverArgs>,
+ ObjectRecord[]
+ >('createMany', { data: [args.data], upsert: args.upsert }, options);
+
+ // TODO: emitCreateEvents should be moved to the ORM layer
+ if (results) {
+ this.apiEventEmitterService.emitCreateEvents(
+ results,
+ options.authContext,
+ options.objectMetadataItem,
);
+ }
- const computedArgs = (await this.queryRunnerArgsFactory.create(
- hookedArgs,
- options,
- ResolverArgsType.FindMany,
- )) as FindManyResolverArgs;
-
- return graphqlQueryFindManyResolverService.findMany(computedArgs, options);
+ return results[0];
}
@LogExecutionTime()
- async createOne(
- args: CreateOneResolverArgs>,
+ async createMany(
+ args: CreateManyResolverArgs>,
options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const graphqlQueryCreateManyResolverService =
- new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager);
+ ): Promise {
+ const results = await this.executeQuery<
+ CreateManyResolverArgs>,
+ ObjectRecord[]
+ >('createMany', args, options);
+
+ if (results) {
+ this.apiEventEmitterService.emitCreateEvents(
+ results,
+ options.authContext,
+ options.objectMetadataItem,
+ );
+ }
- const { authContext, objectMetadataItem } = options;
+ return results;
+ }
- assertMutationNotOnRemoteObject(objectMetadataItem);
+ @LogExecutionTime()
+ public async updateOne(
+ args: UpdateOneResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const existingRecord = await this.executeQuery<
+ FindOneResolverArgs,
+ ObjectRecord
+ >(
+ 'findOne',
+ {
+ filter: { id: { eq: args.id } },
+ },
+ options,
+ );
- if (args.data.id) {
- assertIsValidUuid(args.data.id);
- }
+ const result = await this.executeQuery<
+ UpdateOneResolverArgs>,
+ ObjectRecord
+ >('updateOne', args, options);
- const createManyArgs = {
- data: [args.data],
- upsert: args.upsert,
- } as CreateManyResolverArgs;
+ this.apiEventEmitterService.emitUpdateEvents(
+ [existingRecord],
+ [result],
+ Object.keys(args.data),
+ options.authContext,
+ options.objectMetadataItem,
+ );
- const hookedArgs =
- await this.workspaceQueryHookService.executePreQueryHooks(
- authContext,
- objectMetadataItem.nameSingular,
- 'createMany',
- createManyArgs,
- );
+ return result;
+ }
- const computedArgs = (await this.queryRunnerArgsFactory.create(
- hookedArgs,
+ @LogExecutionTime()
+ public async updateMany(
+ args: UpdateManyResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const existingRecords = await this.executeQuery<
+ FindManyResolverArgs,
+ IConnection>
+ >(
+ 'findMany',
+ {
+ filter: args.filter,
+ },
options,
- ResolverArgsType.CreateMany,
- )) as CreateManyResolverArgs;
+ );
+
+ const result = await this.executeQuery<
+ UpdateManyResolverArgs>,
+ ObjectRecord[]
+ >('updateMany', args, options);
+
+ this.apiEventEmitterService.emitUpdateEvents(
+ existingRecords.edges.map((edge) => edge.node),
+ result,
+ Object.keys(args.data),
+ options.authContext,
+ options.objectMetadataItem,
+ );
- const results = (await graphqlQueryCreateManyResolverService.createMany(
- computedArgs,
+ return result;
+ }
+
+ @LogExecutionTime()
+ public async deleteOne(
+ args: DeleteOneResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const result = await this.executeQuery<
+ UpdateOneResolverArgs>,
+ ObjectRecord
+ >(
+ 'deleteOne',
+ {
+ id: args.id,
+ data: { deletedAt: new Date() } as Partial,
+ },
options,
- )) as ObjectRecord[];
+ );
+
+ this.apiEventEmitterService.emitDeletedEvents(
+ [result],
+ options.authContext,
+ options.objectMetadataItem,
+ );
- await this.triggerWebhooks(
- results,
- CallWebhookJobsJobOperation.create,
+ return result;
+ }
+
+ @LogExecutionTime()
+ public async deleteMany(
+ args: DeleteManyResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const result = await this.executeQuery<
+ UpdateManyResolverArgs>,
+ ObjectRecord[]
+ >(
+ 'deleteMany',
+ {
+ filter: args.filter,
+
+ data: { deletedAt: new Date() } as Partial,
+ },
options,
);
- this.emitCreateEvents(
- results,
- authContext,
- objectMetadataItem,
+ this.apiEventEmitterService.emitDeletedEvents(
+ result,
+ options.authContext,
+ options.objectMetadataItem,
);
- return results?.[0] as ObjectRecord;
+ return result;
}
@LogExecutionTime()
- async search(
- args: SearchResolverArgs,
+ async destroyOne(
+ args: DestroyOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
- ): Promise> {
- const graphqlQuerySearchResolverService =
- new GraphqlQuerySearchResolverService(
- this.twentyORMGlobalManager,
- this.featureFlagService,
- );
+ ): Promise {
+ const result = await this.executeQuery<
+ DestroyOneResolverArgs,
+ ObjectRecord
+ >('destroyOne', args, options);
+
+ this.apiEventEmitterService.emitDestroyEvents(
+ [result],
+ options.authContext,
+ options.objectMetadataItem,
+ );
- return graphqlQuerySearchResolverService.search(args, options);
+ return result;
}
@LogExecutionTime()
- async createMany(
- args: CreateManyResolverArgs>,
+ public async restoreMany(
+ args: RestoreManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const graphqlQueryCreateManyResolverService =
- new GraphqlQueryCreateManyResolverService(this.twentyORMGlobalManager);
+ ): Promise {
+ const result = await this.executeQuery<
+ UpdateManyResolverArgs>,
+ ObjectRecord
+ >(
+ 'restoreMany',
+ {
+ filter: args.filter,
+ data: { deletedAt: null } as Partial,
+ },
+ options,
+ );
+
+ return result;
+ }
+ private async executeQuery(
+ operationName: WorkspaceResolverBuilderMethodNames,
+ args: Input,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
const { authContext, objectMetadataItem } = options;
- assertMutationNotOnRemoteObject(objectMetadataItem);
+ const resolver =
+ this.graphqlQueryResolverFactory.getResolver(operationName);
- args.data.forEach((record) => {
- if (record?.id) {
- assertIsValidUuid(record.id);
- }
- });
+ await resolver.validate(args, options);
const hookedArgs =
await this.workspaceQueryHookService.executePreQueryHooks(
authContext,
objectMetadataItem.nameSingular,
- 'createMany',
+ operationName,
args,
);
- const computedArgs = (await this.queryRunnerArgsFactory.create(
+ const computedArgs = await this.queryRunnerArgsFactory.create(
hookedArgs,
options,
- ResolverArgsType.CreateMany,
- )) as CreateManyResolverArgs;
+ ResolverArgsType[capitalize(operationName)],
+ );
- const results = (await graphqlQueryCreateManyResolverService.createMany(
- computedArgs,
- options,
- )) as ObjectRecord[];
+ const results = await resolver.resolve(computedArgs as Input, options);
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItem.nameSingular,
- 'createMany',
- results,
+ operationName,
+ Array.isArray(results) ? results : [results],
);
- await this.triggerWebhooks(
- results,
- CallWebhookJobsJobOperation.create,
- options,
- );
+ const jobOperation = this.operationNameToJobOperation(operationName);
- this.emitCreateEvents(
- results,
- authContext,
- objectMetadataItem,
- );
+ if (jobOperation) {
+ await this.triggerWebhooks(results, jobOperation, options);
+ }
return results;
}
- private emitCreateEvents(
- records: BaseRecord[],
- authContext: AuthContext,
- objectMetadataItem: ObjectMetadataInterface,
- ) {
- this.workspaceEventEmitter.emit(
- `${objectMetadataItem.nameSingular}.created`,
- records.map(
- (record) =>
- ({
- userId: authContext.user?.id,
- recordId: record.id,
- objectMetadata: objectMetadataItem,
- properties: {
- after: record,
- },
- }) satisfies ObjectRecordCreateEvent,
- ),
- authContext.workspace.id,
- );
+ private operationNameToJobOperation(
+ operationName: WorkspaceResolverBuilderMethodNames,
+ ): CallWebhookJobsJobOperation | undefined {
+ switch (operationName) {
+ case 'createOne':
+ case 'createMany':
+ return CallWebhookJobsJobOperation.create;
+ case 'updateOne':
+ case 'updateMany':
+ case 'restoreMany':
+ return CallWebhookJobsJobOperation.update;
+ case 'deleteOne':
+ case 'deleteMany':
+ return CallWebhookJobsJobOperation.delete;
+ case 'destroyOne':
+ return CallWebhookJobsJobOperation.destroy;
+ default:
+ return undefined;
+ }
}
- private async triggerWebhooks(
- jobsData: Record[] | undefined,
+ private async triggerWebhooks(
+ jobsData: T[] | undefined,
operation: CallWebhookJobsJobOperation,
options: WorkspaceQueryRunnerOptions,
- ) {
- if (!Array.isArray(jobsData)) {
- return;
- }
+ ): Promise {
+ if (!jobsData || !Array.isArray(jobsData)) return;
+
jobsData.forEach((jobData) => {
this.messageQueueService.add(
CallWebhookJobsJob.name,
@@ -298,99 +390,4 @@ export class GraphqlQueryRunnerService {
);
});
}
-
- @LogExecutionTime()
- async destroyOne(
- args: DestroyOneResolverArgs,
- options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const graphqlQueryDestroyOneResolverService =
- new GraphqlQueryDestroyOneResolverService(this.twentyORMGlobalManager);
-
- const { authContext, objectMetadataItem } = options;
-
- assertMutationNotOnRemoteObject(objectMetadataItem);
- assertIsValidUuid(args.id);
-
- const hookedArgs =
- await this.workspaceQueryHookService.executePreQueryHooks(
- authContext,
- objectMetadataItem.nameSingular,
- 'destroyOne',
- args,
- );
-
- const computedArgs = (await this.queryRunnerArgsFactory.create(
- hookedArgs,
- options,
- ResolverArgsType.DestroyOne,
- )) as DestroyOneResolverArgs;
-
- const result = (await graphqlQueryDestroyOneResolverService.destroyOne(
- computedArgs,
- options,
- )) as ObjectRecord;
-
- await this.workspaceQueryHookService.executePostQueryHooks(
- authContext,
- objectMetadataItem.nameSingular,
- 'destroyOne',
- [result],
- );
-
- await this.triggerWebhooks(
- [result],
- CallWebhookJobsJobOperation.destroy,
- options,
- );
-
- this.emitDestroyEvents([result], authContext, objectMetadataItem);
-
- return result;
- }
-
- private emitDestroyEvents(
- records: BaseRecord[],
- authContext: AuthContext,
- objectMetadataItem: ObjectMetadataInterface,
- ) {
- this.workspaceEventEmitter.emit(
- `${objectMetadataItem.nameSingular}.destroyed`,
- records.map((record) => {
- return {
- userId: authContext.user?.id,
- recordId: record.id,
- objectMetadata: objectMetadataItem,
- properties: {
- before: this.removeNestedProperties(record),
- },
- } satisfies ObjectRecordDeleteEvent;
- }),
- authContext.workspace.id,
- );
- }
-
- private removeNestedProperties(
- record: Record,
- ) {
- if (!record) {
- return;
- }
-
- const sanitizedRecord = {};
-
- for (const [key, value] of Object.entries(record)) {
- if (value && typeof value === 'object' && value['edges']) {
- continue;
- }
-
- if (key === '__typename') {
- continue;
- }
-
- sanitizedRecord[key] = value;
- }
-
- return sanitizedRecord;
- }
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts
similarity index 99%
rename from packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts
rename to packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts
index b9a81ef245dc..5ccff3af114d 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts
@@ -20,7 +20,7 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { isPlainObject } from 'src/utils/is-plain-object';
-export class ObjectRecordsToGraphqlConnectionMapper {
+export class ObjectRecordsToGraphqlConnectionHelper {
private objectMetadataMap: ObjectMetadataMap;
constructor(objectMetadataMap: ObjectMetadataMap) {
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts
index f19c7cf06eb3..dd3e5abd4020 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper.ts
@@ -4,6 +4,7 @@ import {
FindOptionsRelations,
In,
ObjectLiteral,
+ Repository,
} from 'typeorm';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
@@ -16,17 +17,38 @@ import {
ObjectMetadataMap,
ObjectMetadataMapItem,
} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
-import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { deduceRelationDirection } from 'src/engine/utils/deduce-relation-direction.util';
export class ProcessNestedRelationsHelper {
- private readonly twentyORMGlobalManager: TwentyORMGlobalManager;
+ constructor() {}
- constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
+ public async processNestedRelations(
+ objectMetadataMap: ObjectMetadataMap,
+ parentObjectMetadataItem: ObjectMetadataMapItem,
+ parentObjectRecords: ObjectRecord[],
+ relations: Record>,
+ limit: number,
+ authContext: any,
+ dataSource: DataSource,
+ ): Promise {
+ const processRelationTasks = Object.entries(relations).map(
+ ([relationName, nestedRelations]) =>
+ this.processRelation(
+ objectMetadataMap,
+ parentObjectMetadataItem,
+ parentObjectRecords,
+ relationName,
+ nestedRelations,
+ limit,
+ authContext,
+ dataSource,
+ ),
+ );
+
+ await Promise.all(processRelationTasks);
}
- private async processFromRelation(
+ private async processRelation(
objectMetadataMap: ObjectMetadataMap,
parentObjectMetadataItem: ObjectMetadataMapItem,
parentObjectRecords: ObjectRecord[],
@@ -35,49 +57,71 @@ export class ProcessNestedRelationsHelper {
limit: number,
authContext: any,
dataSource: DataSource,
- ) {
+ ): Promise {
const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
const relationMetadata = getRelationMetadata(relationFieldMetadata);
-
- const inverseRelationName =
- objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[
- relationMetadata.toFieldMetadataId
- ]?.name;
-
- const referenceObjectMetadata = getRelationObjectMetadata(
+ const relationDirection = deduceRelationDirection(
relationFieldMetadata,
- objectMetadataMap,
+ relationMetadata,
);
- const referenceObjectMetadataName = referenceObjectMetadata.nameSingular;
+ const processor =
+ relationDirection === 'to'
+ ? this.processToRelation
+ : this.processFromRelation;
- const relationRepository = await dataSource.getRepository(
- referenceObjectMetadataName,
+ await processor.call(
+ this,
+ objectMetadataMap,
+ parentObjectMetadataItem,
+ parentObjectRecords,
+ relationName,
+ nestedRelations,
+ limit,
+ authContext,
+ dataSource,
);
+ }
- const relationIds = parentObjectRecords.map((item) => item.id);
-
- const uniqueRelationIds = [...new Set(relationIds)];
-
- const relationFindOptions: FindManyOptions = {
- where: {
- [`${inverseRelationName}Id`]: In(uniqueRelationIds),
- },
- take: limit * parentObjectRecords.length,
- };
+ private async processFromRelation(
+ objectMetadataMap: ObjectMetadataMap,
+ parentObjectMetadataItem: ObjectMetadataMapItem,
+ parentObjectRecords: ObjectRecord[],
+ relationName: string,
+ nestedRelations: any,
+ limit: number,
+ authContext: any,
+ dataSource: DataSource,
+ ): Promise {
+ const { inverseRelationName, referenceObjectMetadata } =
+ this.getRelationMetadata(
+ objectMetadataMap,
+ parentObjectMetadataItem,
+ relationName,
+ );
+ const relationRepository = dataSource.getRepository(
+ referenceObjectMetadata.nameSingular,
+ );
- const relationResults = await relationRepository.find(relationFindOptions);
+ const relationIds = this.getUniqueIds(parentObjectRecords, 'id');
+ const relationResults = await this.findRelations(
+ relationRepository,
+ inverseRelationName,
+ relationIds,
+ limit * parentObjectRecords.length,
+ );
- parentObjectRecords.forEach((item) => {
- (item as any)[relationName] = relationResults.filter(
- (rel) => rel[`${inverseRelationName}Id`] === item.id,
- );
- });
+ this.assignRelationResults(
+ parentObjectRecords,
+ relationResults,
+ relationName,
+ `${inverseRelationName}Id`,
+ );
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations(
objectMetadataMap,
- objectMetadataMap[referenceObjectMetadataName],
+ objectMetadataMap[referenceObjectMetadata.nameSingular],
relationResults as ObjectRecord[],
nestedRelations as Record>,
limit,
@@ -96,48 +140,37 @@ export class ProcessNestedRelationsHelper {
limit: number,
authContext: any,
dataSource: DataSource,
- ) {
- const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
-
- const referenceObjectMetadata = getRelationObjectMetadata(
- relationFieldMetadata,
+ ): Promise {
+ const { referenceObjectMetadata } = this.getRelationMetadata(
objectMetadataMap,
+ parentObjectMetadataItem,
+ relationName,
);
-
- const referenceObjectMetadataName = referenceObjectMetadata.nameSingular;
-
const relationRepository = dataSource.getRepository(
- referenceObjectMetadataName,
+ referenceObjectMetadata.nameSingular,
);
- const relationIds = parentObjectRecords.map(
- (item) => item[`${relationName}Id`],
+ const relationIds = this.getUniqueIds(
+ parentObjectRecords,
+ `${relationName}Id`,
+ );
+ const relationResults = await this.findRelations(
+ relationRepository,
+ 'id',
+ relationIds,
+ limit,
);
- const uniqueRelationIds = [...new Set(relationIds)];
-
- const relationFindOptions: FindManyOptions = {
- where: {
- id: In(uniqueRelationIds),
- },
- take: limit,
- };
-
- const relationResults = await relationRepository.find(relationFindOptions);
-
- parentObjectRecords.forEach((item) => {
- if (relationResults.length === 0) {
- (item as any)[`${relationName}Id`] = null;
- }
- (item as any)[relationName] = relationResults.filter(
- (rel) => rel.id === item[`${relationName}Id`],
- )[0];
- });
+ this.assignToRelationResults(
+ parentObjectRecords,
+ relationResults,
+ relationName,
+ );
if (Object.keys(nestedRelations).length > 0) {
await this.processNestedRelations(
objectMetadataMap,
- objectMetadataMap[referenceObjectMetadataName],
+ objectMetadataMap[referenceObjectMetadata.nameSingular],
relationResults as ObjectRecord[],
nestedRelations as Record>,
limit,
@@ -147,48 +180,71 @@ export class ProcessNestedRelationsHelper {
}
}
- public async processNestedRelations(
+ private getRelationMetadata(
objectMetadataMap: ObjectMetadataMap,
parentObjectMetadataItem: ObjectMetadataMapItem,
- parentObjectRecords: ObjectRecord[],
- relations: Record>,
- limit: number,
- authContext: any,
- dataSource: DataSource,
+ relationName: string,
) {
- for (const [relationName, nestedRelations] of Object.entries(relations)) {
- const relationFieldMetadata =
- parentObjectMetadataItem.fields[relationName];
- const relationMetadata = getRelationMetadata(relationFieldMetadata);
-
- const relationDirection = deduceRelationDirection(
- relationFieldMetadata,
- relationMetadata,
+ const relationFieldMetadata = parentObjectMetadataItem.fields[relationName];
+ const relationMetadata = getRelationMetadata(relationFieldMetadata);
+ const referenceObjectMetadata = getRelationObjectMetadata(
+ relationFieldMetadata,
+ objectMetadataMap,
+ );
+ const inverseRelationName =
+ objectMetadataMap[relationMetadata.toObjectMetadataId]?.fields[
+ relationMetadata.toFieldMetadataId
+ ]?.name;
+
+ return { inverseRelationName, referenceObjectMetadata };
+ }
+
+ private getUniqueIds(records: IRecord[], idField: string): any[] {
+ return [...new Set(records.map((item) => item[idField]))];
+ }
+
+ private async findRelations(
+ repository: Repository,
+ field: string,
+ ids: any[],
+ limit: number,
+ ): Promise {
+ if (ids.length === 0) {
+ return [];
+ }
+ const findOptions: FindManyOptions = {
+ where: { [field]: In(ids) },
+ take: limit,
+ };
+
+ return repository.find(findOptions);
+ }
+
+ private assignRelationResults(
+ parentRecords: IRecord[],
+ relationResults: any[],
+ relationName: string,
+ joinField: string,
+ ): void {
+ parentRecords.forEach((item) => {
+ (item as any)[relationName] = relationResults.filter(
+ (rel) => rel[joinField] === item.id,
);
+ });
+ }
- if (relationDirection === 'to') {
- await this.processToRelation(
- objectMetadataMap,
- parentObjectMetadataItem,
- parentObjectRecords,
- relationName,
- nestedRelations,
- limit,
- authContext,
- dataSource,
- );
- } else {
- await this.processFromRelation(
- objectMetadataMap,
- parentObjectMetadataItem,
- parentObjectRecords,
- relationName,
- nestedRelations,
- limit,
- authContext,
- dataSource,
- );
+ private assignToRelationResults(
+ parentRecords: IRecord[],
+ relationResults: any[],
+ relationName: string,
+ ): void {
+ parentRecords.forEach((item) => {
+ if (relationResults.length === 0) {
+ (item as any)[`${relationName}Id`] = null;
}
- }
+ (item as any)[relationName] =
+ relationResults.find((rel) => rel.id === item[`${relationName}Id`]) ??
+ null;
+ });
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts
new file mode 100644
index 000000000000..f88691647425
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface.ts
@@ -0,0 +1,12 @@
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+
+export interface ResolverService {
+ resolve: (
+ args: ResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ) => Promise;
+ validate: (
+ args: ResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ) => Promise;
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts
index f66497a6812a..aa8a81ce85a6 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service.ts
@@ -1,51 +1,53 @@
+import { Injectable } from '@nestjs/common';
+
import graphqlFields from 'graphql-fields';
import { In, InsertResult } from 'typeorm';
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { CreateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
-import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
-import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
-import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
+import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
+import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
+import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
-export class GraphqlQueryCreateManyResolverService {
- private twentyORMGlobalManager: TwentyORMGlobalManager;
-
- constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
- }
+@Injectable()
+export class GraphqlQueryCreateManyResolverService
+ implements ResolverService
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
- async createMany(
+ async resolve(
args: CreateManyResolverArgs>,
options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const { authContext, objectMetadataItem, objectMetadataCollection, info } =
+ ): Promise {
+ const { authContext, info, objectMetadataMap, objectMetadataMapItem } =
options;
- const repository =
- await this.twentyORMGlobalManager.getRepositoryForWorkspace(
+ const dataSource =
+ await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
- objectMetadataItem.nameSingular,
);
-
- const objectMetadataMap = generateObjectMetadataMap(
- objectMetadataCollection,
- );
- const objectMetadata = getObjectMetadataOrThrow(
- objectMetadataMap,
- objectMetadataItem.nameSingular,
+ const repository = dataSource.getRepository(
+ objectMetadataMapItem.nameSingular,
);
+
const graphqlQueryParser = new GraphqlQueryParser(
- objectMetadata.fields,
+ objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
- const { select, relations } = graphqlQueryParser.parseSelectedFields(
- objectMetadataItem,
+ const { relations } = graphqlQueryParser.parseSelectedFields(
+ objectMetadataMapItem,
selectedFields,
);
@@ -56,24 +58,59 @@ export class GraphqlQueryCreateManyResolverService {
skipUpdateIfNoValuesChanged: true,
});
- const upsertedRecords = await repository.find({
- where: {
+ const queryBuilder = repository.createQueryBuilder(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const nonFormattedUpsertedRecords = (await queryBuilder
+ .where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
- },
- select,
- relations,
- });
+ })
+ .take(QUERY_MAX_RECORDS)
+ .getMany()) as ObjectRecord[];
+
+ const upsertedRecords = formatResult(
+ nonFormattedUpsertedRecords,
+ objectMetadataMapItem,
+ objectMetadataMap,
+ );
+
+ const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
+
+ if (relations) {
+ await processNestedRelationsHelper.processNestedRelations(
+ objectMetadataMap,
+ objectMetadataMapItem,
+ upsertedRecords,
+ relations,
+ QUERY_MAX_RECORDS,
+ authContext,
+ dataSource,
+ );
+ }
const typeORMObjectRecordsParser =
- new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return upsertedRecords.map((record: ObjectRecord) =>
typeORMObjectRecordsParser.processRecord(
record,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
1,
1,
),
);
}
+
+ async validate(
+ args: CreateManyResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ assertMutationNotOnRemoteObject(options.objectMetadataItem);
+ args.data.forEach((record) => {
+ if (record?.id) {
+ assertIsValidUuid(record.id);
+ }
+ });
+ }
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts
index 53dad3eddd0b..3540dcbf9559 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-destroy-one-resolver.service.ts
@@ -1,33 +1,68 @@
+import { Injectable } from '@nestjs/common';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { DestroyOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+import {
+ GraphqlQueryRunnerException,
+ GraphqlQueryRunnerExceptionCode,
+} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
-export class GraphqlQueryDestroyOneResolverService {
- private twentyORMGlobalManager: TwentyORMGlobalManager;
-
- constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
- }
+@Injectable()
+export class GraphqlQueryDestroyOneResolverService
+ implements ResolverService
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
- async destroyOne(
+ async resolve(
args: DestroyOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise {
- const { authContext, objectMetadataItem } = options;
+ const { authContext, objectMetadataMapItem, objectMetadataMap } = options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
authContext.workspace.id,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
);
- const record = await repository.findOne({
+ const nonFormattedRecordBeforeDeletion = await repository.findOne({
where: { id: args.id },
+ withDeleted: true,
});
+ if (!nonFormattedRecordBeforeDeletion) {
+ throw new GraphqlQueryRunnerException(
+ 'Record not found',
+ GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
+ );
+ }
+
+ const recordBeforeDeletion = formatResult(
+ [nonFormattedRecordBeforeDeletion],
+ objectMetadataMapItem,
+ objectMetadataMap,
+ )[0];
+
await repository.delete(args.id);
- return record as ObjectRecord;
+ return recordBeforeDeletion as ObjectRecord;
+ }
+
+ async validate(
+ args: DestroyOneResolverArgs,
+ _options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ if (!args.id) {
+ throw new GraphqlQueryRunnerException(
+ 'Missing id',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts
new file mode 100644
index 000000000000..00561933043d
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service.ts
@@ -0,0 +1,214 @@
+import { Injectable } from '@nestjs/common';
+
+import isEmpty from 'lodash.isempty';
+import { In } from 'typeorm';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
+import {
+ Record as IRecord,
+ OrderByDirection,
+ RecordFilter,
+} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+import { FindDuplicatesResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+
+import {
+ GraphqlQueryRunnerException,
+ GraphqlQueryRunnerExceptionCode,
+} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
+import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
+import { settings } from 'src/engine/constants/settings';
+import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants';
+import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
+
+@Injectable()
+export class GraphqlQueryFindDuplicatesResolverService
+ implements
+ ResolverService[]>
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
+
+ async resolve(
+ args: FindDuplicatesResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise[]> {
+ const { authContext, objectMetadataMapItem, objectMetadataMap } = options;
+
+ const dataSource =
+ await this.twentyORMGlobalManager.getDataSourceForWorkspace(
+ authContext.workspace.id,
+ );
+ const repository = dataSource.getRepository(
+ objectMetadataMapItem.nameSingular,
+ );
+ const existingRecordsQueryBuilder = repository.createQueryBuilder(
+ objectMetadataMapItem.nameSingular,
+ );
+ const duplicateRecordsQueryBuilder = repository.createQueryBuilder(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const graphqlQueryParser = new GraphqlQueryParser(
+ objectMetadataMap[objectMetadataMapItem.nameSingular].fields,
+ objectMetadataMap,
+ );
+
+ const typeORMObjectRecordsParser =
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
+
+ let objectRecords: Partial[] = [];
+
+ if (args.ids) {
+ const nonFormattedObjectRecords = (await existingRecordsQueryBuilder
+ .where({ id: In(args.ids) })
+ .getMany()) as ObjectRecord[];
+
+ objectRecords = formatResult(
+ nonFormattedObjectRecords,
+ objectMetadataMapItem,
+ objectMetadataMap,
+ );
+ } else if (args.data && !isEmpty(args.data)) {
+ objectRecords = formatData(args.data, objectMetadataMapItem);
+ }
+
+ const duplicateConnections: IConnection[] = await Promise.all(
+ objectRecords.map(async (record) => {
+ const duplicateConditions = this.buildDuplicateConditions(
+ objectMetadataMapItem,
+ [record],
+ record.id,
+ );
+
+ if (isEmpty(duplicateConditions)) {
+ return typeORMObjectRecordsParser.createConnection(
+ [],
+ objectMetadataMapItem.nameSingular,
+ 0,
+ 0,
+ [{ id: OrderByDirection.AscNullsFirst }],
+ false,
+ false,
+ );
+ }
+
+ const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
+ duplicateRecordsQueryBuilder,
+ objectMetadataMapItem.nameSingular,
+ duplicateConditions,
+ );
+
+ const nonFormattedDuplicates =
+ (await withFilterQueryBuilder.getMany()) as ObjectRecord[];
+
+ const duplicates = formatResult(
+ nonFormattedDuplicates,
+ objectMetadataMapItem,
+ objectMetadataMap,
+ );
+
+ return typeORMObjectRecordsParser.createConnection(
+ duplicates,
+ objectMetadataMapItem.nameSingular,
+ duplicates.length,
+ duplicates.length,
+ [{ id: OrderByDirection.AscNullsFirst }],
+ false,
+ false,
+ );
+ }),
+ );
+
+ return duplicateConnections;
+ }
+
+ private buildDuplicateConditions(
+ objectMetadataMapItem: ObjectMetadataMapItem,
+ records?: Partial[] | undefined,
+ filteringByExistingRecordId?: string,
+ ): Partial {
+ if (!records || records.length === 0) {
+ return {};
+ }
+
+ const criteriaCollection = this.getApplicableDuplicateCriteriaCollection(
+ objectMetadataMapItem,
+ );
+
+ const conditions = records.flatMap((record) => {
+ const criteriaWithMatchingArgs = criteriaCollection.filter((criteria) =>
+ criteria.columnNames.every((columnName) => {
+ const value = record[columnName] as string | undefined;
+
+ return (
+ value && value.length >= settings.minLengthOfStringForDuplicateCheck
+ );
+ }),
+ );
+
+ return criteriaWithMatchingArgs.map((criteria) => {
+ const condition = {};
+
+ criteria.columnNames.forEach((columnName) => {
+ condition[columnName] = { eq: record[columnName] };
+ });
+
+ return condition;
+ });
+ });
+
+ const filter: Partial = {};
+
+ if (conditions && !isEmpty(conditions)) {
+ filter.or = conditions;
+
+ if (filteringByExistingRecordId) {
+ filter.id = { neq: filteringByExistingRecordId };
+ }
+ }
+
+ return filter;
+ }
+
+ private getApplicableDuplicateCriteriaCollection(
+ objectMetadataMapItem: ObjectMetadataMapItem,
+ ) {
+ return DUPLICATE_CRITERIA_COLLECTION.filter(
+ (duplicateCriteria) =>
+ duplicateCriteria.objectName === objectMetadataMapItem.nameSingular,
+ );
+ }
+
+ async validate(
+ args: FindDuplicatesResolverArgs,
+ _options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ if (!args.data && !args.ids) {
+ throw new GraphqlQueryRunnerException(
+ 'You have to provide either "data" or "ids" argument',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+
+ if (args.data && args.ids) {
+ throw new GraphqlQueryRunnerException(
+ 'You cannot provide both "data" and "ids" arguments',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+
+ if (!args.ids && isEmpty(args.data)) {
+ throw new GraphqlQueryRunnerException(
+ 'The "data" condition can not be empty when "ids" input not provided',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts
index 5caa30e4e51e..85fdd3948274 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service.ts
@@ -1,6 +1,9 @@
+import { Injectable } from '@nestjs/common';
+
import { isDefined } from 'class-validator';
import graphqlFields from 'graphql-fields';
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
OrderByDirection,
@@ -17,26 +20,25 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
-import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
import { computeCursorArgFilter } from 'src/engine/api/graphql/graphql-query-runner/utils/compute-cursor-arg-filter';
-import { decodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
-import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
import {
- ObjectMetadataMapItem,
- generateObjectMetadataMap,
-} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
+ getCursor,
+ getPaginationInfo,
+} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
-export class GraphqlQueryFindManyResolverService {
- private twentyORMGlobalManager: TwentyORMGlobalManager;
-
- constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
- }
+@Injectable()
+export class GraphqlQueryFindManyResolverService
+ implements ResolverService>
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
- async findMany<
+ async resolve<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
OrderBy extends RecordOrderBy = RecordOrderBy,
@@ -44,51 +46,41 @@ export class GraphqlQueryFindManyResolverService {
args: FindManyResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise> {
- const { authContext, objectMetadataItem, info, objectMetadataCollection } =
+ const { authContext, objectMetadataMapItem, info, objectMetadataMap } =
options;
- this.validateArgsOrThrow(args);
-
const dataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace(
authContext.workspace.id,
);
const repository = dataSource.getRepository(
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
);
const queryBuilder = repository.createQueryBuilder(
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
);
const countQueryBuilder = repository.createQueryBuilder(
- objectMetadataItem.nameSingular,
- );
-
- const objectMetadataMap = generateObjectMetadataMap(
- objectMetadataCollection,
+ objectMetadataMapItem.nameSingular,
);
- const objectMetadata = getObjectMetadataOrThrow(
- objectMetadataMap,
- objectMetadataItem.nameSingular,
- );
const graphqlQueryParser = new GraphqlQueryParser(
- objectMetadata.fields,
+ objectMetadataMapItem.fields,
objectMetadataMap,
);
const withFilterCountQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
countQueryBuilder,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
args.filter ?? ({} as Filter),
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
- objectMetadataItem,
+ objectMetadataMapItem,
selectedFields,
);
const isForwardPagination = !isDefined(args.before);
@@ -105,7 +97,7 @@ export class GraphqlQueryFindManyResolverService {
? await withDeletedCountQueryBuilder.getCount()
: 0;
- const cursor = this.getCursor(args);
+ const cursor = getCursor(args);
let appliedFilters = args.filter ?? ({} as Filter);
@@ -118,7 +110,7 @@ export class GraphqlQueryFindManyResolverService {
const cursorArgFilter = computeCursorArgFilter(
cursor,
orderByWithIdCondition,
- objectMetadata.fields,
+ objectMetadataMapItem.fields,
isForwardPagination,
);
@@ -131,14 +123,14 @@ export class GraphqlQueryFindManyResolverService {
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
appliedFilters,
);
const withOrderByQueryBuilder = graphqlQueryParser.applyOrderToBuilder(
withFilterQueryBuilder,
orderByWithIdCondition,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
isForwardPagination,
);
@@ -153,11 +145,11 @@ export class GraphqlQueryFindManyResolverService {
const objectRecords = formatResult(
nonFormattedObjectRecords,
- objectMetadata,
+ objectMetadataMapItem,
objectMetadataMap,
);
- const { hasNextPage, hasPreviousPage } = this.getPaginationInfo(
+ const { hasNextPage, hasPreviousPage } = getPaginationInfo(
objectRecords,
limit,
isForwardPagination,
@@ -167,14 +159,12 @@ export class GraphqlQueryFindManyResolverService {
objectRecords.pop();
}
- const processNestedRelationsHelper = new ProcessNestedRelationsHelper(
- this.twentyORMGlobalManager,
- );
+ const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
- objectMetadata,
+ objectMetadataMapItem,
objectRecords,
relations,
limit,
@@ -184,20 +174,25 @@ export class GraphqlQueryFindManyResolverService {
}
const typeORMObjectRecordsParser =
- new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
- return typeORMObjectRecordsParser.createConnection(
+ const result = typeORMObjectRecordsParser.createConnection(
objectRecords,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
limit,
totalCount,
orderByWithIdCondition,
hasNextPage,
hasPreviousPage,
);
+
+ return result;
}
- private validateArgsOrThrow(args: FindManyResolverArgs) {
+ async validate(
+ args: FindManyResolverArgs,
+ _options: WorkspaceQueryRunnerOptions,
+ ): Promise {
if (args.first && args.last) {
throw new GraphqlQueryRunnerException(
'Cannot provide both first and last',
@@ -235,49 +230,4 @@ export class GraphqlQueryFindManyResolverService {
);
}
}
-
- private getCursor(
- args: FindManyResolverArgs,
- ): Record | undefined {
- if (args.after) return decodeCursor(args.after);
- if (args.before) return decodeCursor(args.before);
-
- return undefined;
- }
-
- private addOrderByColumnsToSelect(
- order: Record,
- select: Record,
- ) {
- for (const column of Object.keys(order || {})) {
- if (!select[column]) {
- select[column] = true;
- }
- }
- }
-
- private addForeingKeyColumnsToSelect(
- relations: Record,
- select: Record,
- objectMetadata: ObjectMetadataMapItem,
- ) {
- for (const column of Object.keys(relations || {})) {
- if (!select[`${column}Id`] && objectMetadata.fields[`${column}Id`]) {
- select[`${column}Id`] = true;
- }
- }
- }
-
- private getPaginationInfo(
- objectRecords: any[],
- limit: number,
- isForwardPagination: boolean,
- ) {
- const hasMoreRecords = objectRecords.length > limit;
-
- return {
- hasNextPage: isForwardPagination && hasMoreRecords,
- hasPreviousPage: !isForwardPagination && hasMoreRecords,
- };
- }
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts
index b9e86e420cc9..164f30b68664 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service.ts
@@ -1,5 +1,8 @@
+import { Injectable } from '@nestjs/common';
+
import graphqlFields from 'graphql-fields';
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
RecordFilter,
@@ -13,28 +16,31 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
-import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
-import { getObjectMetadataOrThrow } from 'src/engine/api/graphql/graphql-query-runner/utils/get-object-metadata-or-throw.util';
-import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
+import {
+ WorkspaceQueryRunnerException,
+ WorkspaceQueryRunnerExceptionCode,
+} from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
-export class GraphqlQueryFindOneResolverService {
- private twentyORMGlobalManager: TwentyORMGlobalManager;
-
- constructor(twentyORMGlobalManager: TwentyORMGlobalManager) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
- }
+@Injectable()
+export class GraphqlQueryFindOneResolverService
+ implements ResolverService
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
- async findOne<
+ async resolve<
ObjectRecord extends IRecord = IRecord,
Filter extends RecordFilter = RecordFilter,
>(
args: FindOneResolverArgs,
options: WorkspaceQueryRunnerOptions,
- ): Promise {
- const { authContext, objectMetadataItem, info, objectMetadataCollection } =
+ ): Promise {
+ const { authContext, objectMetadataMapItem, info, objectMetadataMap } =
options;
const dataSource =
@@ -43,37 +49,28 @@ export class GraphqlQueryFindOneResolverService {
);
const repository = dataSource.getRepository(
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
);
const queryBuilder = repository.createQueryBuilder(
- objectMetadataItem.nameSingular,
- );
-
- const objectMetadataMap = generateObjectMetadataMap(
- objectMetadataCollection,
- );
-
- const objectMetadata = getObjectMetadataOrThrow(
- objectMetadataMap,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
);
const graphqlQueryParser = new GraphqlQueryParser(
- objectMetadata.fields,
+ objectMetadataMapItem.fields,
objectMetadataMap,
);
const selectedFields = graphqlFields(info);
const { relations } = graphqlQueryParser.parseSelectedFields(
- objectMetadataItem,
+ objectMetadataMapItem,
selectedFields,
);
const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
queryBuilder,
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
args.filter ?? ({} as Filter),
);
@@ -86,12 +83,10 @@ export class GraphqlQueryFindOneResolverService {
const objectRecord = formatResult(
nonFormattedObjectRecord,
- objectMetadata,
+ objectMetadataMapItem,
objectMetadataMap,
);
- const limit = QUERY_MAX_RECORDS;
-
if (!objectRecord) {
throw new GraphqlQueryRunnerException(
'Record not found',
@@ -99,32 +94,42 @@ export class GraphqlQueryFindOneResolverService {
);
}
- const processNestedRelationsHelper = new ProcessNestedRelationsHelper(
- this.twentyORMGlobalManager,
- );
+ const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
const objectRecords = [objectRecord];
if (relations) {
await processNestedRelationsHelper.processNestedRelations(
objectMetadataMap,
- objectMetadata,
+ objectMetadataMapItem,
objectRecords,
relations,
- limit,
+ QUERY_MAX_RECORDS,
authContext,
dataSource,
);
}
const typeORMObjectRecordsParser =
- new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
return typeORMObjectRecordsParser.processRecord(
objectRecords[0],
- objectMetadataItem.nameSingular,
+ objectMetadataMapItem.nameSingular,
1,
1,
) as ObjectRecord;
}
+
+ async validate(
+ args: FindOneResolverArgs,
+ _options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ if (!args.filter || Object.keys(args.filter).length === 0) {
+ throw new WorkspaceQueryRunnerException(
+ 'Missing filter argument',
+ WorkspaceQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+ }
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts
index c8d1892bbd2b..fe9d86f6cd84 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-search-resolver.service.ts
@@ -1,3 +1,6 @@
+import { Injectable } from '@nestjs/common';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
import {
Record as IRecord,
OrderByDirection,
@@ -11,47 +14,25 @@ import {
GraphqlQueryRunnerException,
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
-import { ObjectRecordsToGraphqlConnectionMapper } from 'src/engine/api/graphql/graphql-query-runner/orm-mappers/object-records-to-graphql-connection.mapper';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
-import { generateObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
-export class GraphqlQuerySearchResolverService {
- private twentyORMGlobalManager: TwentyORMGlobalManager;
- private featureFlagService: FeatureFlagService;
-
+@Injectable()
+export class GraphqlQuerySearchResolverService
+ implements ResolverService>
+{
constructor(
- twentyORMGlobalManager: TwentyORMGlobalManager,
- featureFlagService: FeatureFlagService,
- ) {
- this.twentyORMGlobalManager = twentyORMGlobalManager;
- this.featureFlagService = featureFlagService;
- }
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ private readonly featureFlagService: FeatureFlagService,
+ ) {}
- async search(
+ async resolve(
args: SearchResolverArgs,
options: WorkspaceQueryRunnerOptions,
): Promise> {
- const { authContext, objectMetadataItem, objectMetadataCollection } =
- options;
-
- const featureFlagsForWorkspace =
- await this.featureFlagService.getWorkspaceFeatureFlags(
- authContext.workspace.id,
- );
-
- const isQueryRunnerTwentyORMEnabled =
- featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED;
-
- const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED;
-
- if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) {
- throw new GraphqlQueryRunnerException(
- 'This endpoint is not available yet, please use findMany instead.',
- GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
- );
- }
+ const { authContext, objectMetadataItem, objectMetadataMap } = options;
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
@@ -59,21 +40,8 @@ export class GraphqlQuerySearchResolverService {
objectMetadataItem.nameSingular,
);
- const objectMetadataMap = generateObjectMetadataMap(
- objectMetadataCollection,
- );
-
- const objectMetadata = objectMetadataMap[objectMetadataItem.nameSingular];
-
- if (!objectMetadata) {
- throw new GraphqlQueryRunnerException(
- `Object metadata not found for ${objectMetadataItem.nameSingular}`,
- GraphqlQueryRunnerExceptionCode.OBJECT_METADATA_NOT_FOUND,
- );
- }
-
const typeORMObjectRecordsParser =
- new ObjectRecordsToGraphqlConnectionMapper(objectMetadataMap);
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
if (!args.searchInput) {
return typeORMObjectRecordsParser.createConnection(
@@ -100,7 +68,7 @@ export class GraphqlQuerySearchResolverService {
'DESC',
)
.setParameter('searchTerms', searchTerms)
- .limit(limit)
+ .take(limit)
.getMany()) as ObjectRecord[];
const objectRecords = await repository.formatResult(resultsWithTsVector);
@@ -129,4 +97,26 @@ export class GraphqlQuerySearchResolverService {
return formattedWords.join(' | ');
}
+
+ async validate(
+ _args: SearchResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const featureFlagsForWorkspace =
+ await this.featureFlagService.getWorkspaceFeatureFlags(
+ options.authContext.workspace.id,
+ );
+
+ const isQueryRunnerTwentyORMEnabled =
+ featureFlagsForWorkspace.IS_QUERY_RUNNER_TWENTY_ORM_ENABLED;
+
+ const isSearchEnabled = featureFlagsForWorkspace.IS_SEARCH_ENABLED;
+
+ if (!isQueryRunnerTwentyORMEnabled || !isSearchEnabled) {
+ throw new GraphqlQueryRunnerException(
+ 'This endpoint is not available yet, please use findMany instead.',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+ }
}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
new file mode 100644
index 000000000000..149674209329
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
@@ -0,0 +1,116 @@
+import { Injectable } from '@nestjs/common';
+
+import graphqlFields from 'graphql-fields';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
+import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+import { UpdateManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+
+import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
+import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
+import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
+import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
+import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
+
+@Injectable()
+export class GraphqlQueryUpdateManyResolverService
+ implements ResolverService
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
+
+ async resolve(
+ args: UpdateManyResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const { authContext, objectMetadataMapItem, objectMetadataMap, info } =
+ options;
+
+ const dataSource =
+ await this.twentyORMGlobalManager.getDataSourceForWorkspace(
+ authContext.workspace.id,
+ );
+
+ const repository = dataSource.getRepository(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const graphqlQueryParser = new GraphqlQueryParser(
+ objectMetadataMapItem.fields,
+ objectMetadataMap,
+ );
+
+ const selectedFields = graphqlFields(info);
+
+ const { relations } = graphqlQueryParser.parseSelectedFields(
+ objectMetadataMapItem,
+ selectedFields,
+ );
+
+ const queryBuilder = repository.createQueryBuilder(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const withFilterQueryBuilder = graphqlQueryParser.applyFilterToBuilder(
+ queryBuilder,
+ objectMetadataMapItem.nameSingular,
+ args.filter,
+ );
+
+ const data = formatData(args.data, objectMetadataMapItem);
+
+ const result = await withFilterQueryBuilder
+ .update()
+ .set(data)
+ .returning('*')
+ .execute();
+
+ const nonFormattedUpdatedObjectRecords = result.raw;
+
+ const updatedRecords = formatResult(
+ nonFormattedUpdatedObjectRecords,
+ objectMetadataMapItem,
+ objectMetadataMap,
+ );
+
+ const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
+
+ if (relations) {
+ await processNestedRelationsHelper.processNestedRelations(
+ objectMetadataMap,
+ objectMetadataMapItem,
+ updatedRecords,
+ relations,
+ QUERY_MAX_RECORDS,
+ authContext,
+ dataSource,
+ );
+ }
+
+ const typeORMObjectRecordsParser =
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
+
+ return updatedRecords.map((record: ObjectRecord) =>
+ typeORMObjectRecordsParser.processRecord(
+ record,
+ objectMetadataMapItem.nameSingular,
+ 1,
+ 1,
+ ),
+ );
+ }
+
+ async validate(
+ args: UpdateManyResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ assertMutationNotOnRemoteObject(options.objectMetadataMapItem);
+ args.filter?.id?.in?.forEach((id: string) => assertIsValidUuid(id));
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
new file mode 100644
index 000000000000..7be966cc656c
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
@@ -0,0 +1,123 @@
+import { Injectable } from '@nestjs/common';
+
+import graphqlFields from 'graphql-fields';
+
+import { ResolverService } from 'src/engine/api/graphql/graphql-query-runner/interfaces/resolver-service.interface';
+import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+import { UpdateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+
+import { QUERY_MAX_RECORDS } from 'src/engine/api/graphql/graphql-query-runner/constants/query-max-records.constant';
+import {
+ GraphqlQueryRunnerException,
+ GraphqlQueryRunnerExceptionCode,
+} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
+import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
+import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
+import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
+import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
+import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
+import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
+import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
+
+@Injectable()
+export class GraphqlQueryUpdateOneResolverService
+ implements ResolverService
+{
+ constructor(
+ private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
+ ) {}
+
+ async resolve(
+ args: UpdateOneResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ const { authContext, objectMetadataMapItem, objectMetadataMap, info } =
+ options;
+
+ const dataSource =
+ await this.twentyORMGlobalManager.getDataSourceForWorkspace(
+ authContext.workspace.id,
+ );
+
+ const repository = dataSource.getRepository(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const graphqlQueryParser = new GraphqlQueryParser(
+ objectMetadataMapItem.fields,
+ objectMetadataMap,
+ );
+
+ const selectedFields = graphqlFields(info);
+
+ const { relations } = graphqlQueryParser.parseSelectedFields(
+ objectMetadataMapItem,
+ selectedFields,
+ );
+
+ const queryBuilder = repository.createQueryBuilder(
+ objectMetadataMapItem.nameSingular,
+ );
+
+ const withFilterQueryBuilder = queryBuilder.where({ id: args.id });
+
+ const data = formatData(args.data, objectMetadataMapItem);
+
+ const result = await withFilterQueryBuilder
+ .update()
+ .set(data)
+ .returning('*')
+ .execute();
+
+ const nonFormattedUpdatedObjectRecords = result.raw;
+
+ const updatedRecords = formatResult(
+ nonFormattedUpdatedObjectRecords,
+ objectMetadataMapItem,
+ objectMetadataMap,
+ );
+
+ if (updatedRecords.length === 0) {
+ throw new GraphqlQueryRunnerException(
+ 'Record not found',
+ GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
+ );
+ }
+
+ const updatedRecord = updatedRecords[0] as ObjectRecord;
+
+ const processNestedRelationsHelper = new ProcessNestedRelationsHelper();
+
+ if (relations) {
+ await processNestedRelationsHelper.processNestedRelations(
+ objectMetadataMap,
+ objectMetadataMapItem,
+ [updatedRecord],
+ relations,
+ QUERY_MAX_RECORDS,
+ authContext,
+ dataSource,
+ );
+ }
+
+ const typeORMObjectRecordsParser =
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMap);
+
+ return typeORMObjectRecordsParser.processRecord(
+ updatedRecord,
+ objectMetadataMapItem.nameSingular,
+ 1,
+ 1,
+ );
+ }
+
+ async validate(
+ args: UpdateOneResolverArgs>,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ assertMutationNotOnRemoteObject(options.objectMetadataMapItem);
+ assertIsValidUuid(args.id);
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts
new file mode 100644
index 000000000000..8cb2c9cc7a04
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service.ts
@@ -0,0 +1,137 @@
+import { Injectable } from '@nestjs/common';
+
+import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
+
+import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
+
+@Injectable()
+export class ApiEventEmitterService {
+ constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
+
+ public emitCreateEvents(
+ records: T[],
+ authContext: AuthContext,
+ objectMetadataItem: ObjectMetadataInterface,
+ ): void {
+ this.workspaceEventEmitter.emit(
+ `${objectMetadataItem.nameSingular}.created`,
+ records.map((record) => ({
+ userId: authContext.user?.id,
+ recordId: record.id,
+ objectMetadata: objectMetadataItem,
+ properties: {
+ before: null,
+ after: this.removeGraphQLAndNestedProperties(record),
+ },
+ })),
+ authContext.workspace.id,
+ );
+ }
+
+ public emitUpdateEvents(
+ existingRecords: T[],
+ records: T[],
+ updatedFields: string[],
+ authContext: AuthContext,
+ objectMetadataItem: ObjectMetadataInterface,
+ ): void {
+ const mappedExistingRecords = existingRecords.reduce(
+ (acc, { id, ...record }) => ({
+ ...acc,
+ [id]: record,
+ }),
+ {},
+ );
+
+ this.workspaceEventEmitter.emit(
+ `${objectMetadataItem.nameSingular}.updated`,
+ records.map((record) => {
+ return {
+ userId: authContext.user?.id,
+ recordId: record.id,
+ objectMetadata: objectMetadataItem,
+ properties: {
+ before: mappedExistingRecords[record.id]
+ ? this.removeGraphQLAndNestedProperties(
+ mappedExistingRecords[record.id],
+ )
+ : undefined,
+ after: this.removeGraphQLAndNestedProperties(record),
+ updatedFields,
+ },
+ };
+ }),
+ authContext.workspace.id,
+ );
+ }
+
+ public emitDeletedEvents(
+ records: T[],
+ authContext: AuthContext,
+ objectMetadataItem: ObjectMetadataInterface,
+ ): void {
+ this.workspaceEventEmitter.emit(
+ `${objectMetadataItem.nameSingular}.deleted`,
+ records.map((record) => {
+ return {
+ userId: authContext.user?.id,
+ recordId: record.id,
+ objectMetadata: objectMetadataItem,
+ properties: {
+ before: this.removeGraphQLAndNestedProperties(record),
+ after: null,
+ },
+ };
+ }),
+ authContext.workspace.id,
+ );
+ }
+
+ public emitDestroyEvents(
+ records: T[],
+ authContext: AuthContext,
+ objectMetadataItem: ObjectMetadataInterface,
+ ): void {
+ this.workspaceEventEmitter.emit(
+ `${objectMetadataItem.nameSingular}.destroyed`,
+ records.map((record) => {
+ return {
+ userId: authContext.user?.id,
+ recordId: record.id,
+ objectMetadata: objectMetadataItem,
+ properties: {
+ before: this.removeGraphQLAndNestedProperties(record),
+ after: null,
+ },
+ };
+ }),
+ authContext.workspace.id,
+ );
+ }
+
+ private removeGraphQLAndNestedProperties(
+ record: ObjectRecord,
+ ) {
+ if (!record) {
+ return {};
+ }
+
+ const sanitizedRecord = {};
+
+ for (const [key, value] of Object.entries(record)) {
+ if (value && typeof value === 'object' && value['edges']) {
+ continue;
+ }
+
+ if (key === '__typename') {
+ continue;
+ }
+
+ sanitizedRecord[key] = value;
+ }
+
+ return sanitizedRecord;
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts
index bf8eb52d0a57..bd27522ce1b2 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/cursors.util.ts
@@ -2,6 +2,7 @@ import {
Record as IRecord,
RecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { FindManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import {
GraphqlQueryRunnerException,
@@ -44,3 +45,25 @@ export const encodeCursor = (
return Buffer.from(JSON.stringify(cursorData)).toString('base64');
};
+
+export const getCursor = (
+ args: FindManyResolverArgs,
+): Record | undefined => {
+ if (args.after) return decodeCursor(args.after);
+ if (args.before) return decodeCursor(args.before);
+
+ return undefined;
+};
+
+export const getPaginationInfo = (
+ objectRecords: any[],
+ limit: number,
+ isForwardPagination: boolean,
+) => {
+ const hasMoreRecords = objectRecords.length > limit;
+
+ return {
+ hasNextPage: isForwardPagination && hasMoreRecords,
+ hasPreviousPage: !isForwardPagination && hasMoreRecords,
+ };
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts
index 45883f99ddf3..d960c3d45a7f 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface.ts
@@ -4,6 +4,10 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
+import {
+ ObjectMetadataMap,
+ ObjectMetadataMapItem,
+} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
export interface WorkspaceQueryRunnerOptions {
authContext: AuthContext;
@@ -11,4 +15,6 @@ export interface WorkspaceQueryRunnerOptions {
objectMetadataItem: ObjectMetadataInterface;
fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[];
+ objectMetadataMap: ObjectMetadataMap;
+ objectMetadataMapItem: ObjectMetadataMapItem;
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
index b75c939d1ac7..034c73bda552 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
@@ -9,6 +9,7 @@ import {
FindManyResolverArgs,
FindOneResolverArgs,
RestoreManyResolverArgs,
+ SearchResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
@@ -42,4 +43,6 @@ export type WorkspacePreQueryHookPayload = T extends 'createMany'
? DestroyManyResolverArgs
: T extends 'destroyOne'
? DestroyOneResolverArgs
- : never;
+ : T extends 'search'
+ ? SearchResolverArgs
+ : never;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts
index 872ab7906fca..06a8d5507b2d 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
CreateManyResolverArgs,
@@ -32,12 +33,14 @@ export class CreateManyResolverFactory
return async (_source, args, _context, info) => {
try {
- const options = {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
};
const isQueryRunnerTwentyORMEnabled =
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts
index edf9206a1cde..5922d05550d6 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
CreateOneResolverArgs,
@@ -32,12 +33,14 @@ export class CreateOneResolverFactory
return async (_source, args, _context, info) => {
try {
- const options = {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
};
const isQueryRunnerTwentyORMEnabled =
@@ -50,13 +53,7 @@ export class CreateOneResolverFactory
return await this.graphqlQueryRunnerService.createOne(args, options);
}
- return await this.workspaceQueryRunnerService.createOne(args, {
- authContext: internalContext.authContext,
- objectMetadataItem: internalContext.objectMetadataItem,
- info,
- fieldMetadataCollection: internalContext.fieldMetadataCollection,
- objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ return await this.workspaceQueryRunnerService.createOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts
index a8d36f3e4900..4a32ad5ea1c5 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
DeleteManyResolverArgs,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class DeleteManyResolverFactory
@@ -18,6 +22,8 @@ export class DeleteManyResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,27 @@ export class DeleteManyResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.deleteMany(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.deleteMany(args, options);
+ }
+
+ return await this.workspaceQueryRunnerService.deleteMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts
index 93f249cdd6ac..d58ebe02fd56 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/delete-one-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
DeleteOneResolverArgs,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class DeleteOneResolverFactory
@@ -18,6 +22,8 @@ export class DeleteOneResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,27 @@ export class DeleteOneResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.deleteOne(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.deleteOne(args, options);
+ }
+
+ return await this.workspaceQueryRunnerService.deleteOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts
index 4a064a406b9b..2e6cf835effa 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
DestroyManyResolverArgs,
@@ -27,13 +28,20 @@ export class DestroyManyResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.destroyMany(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ return await this.workspaceQueryRunnerService.destroyMany(
+ args,
+ options,
+ );
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts
index 4c204d6e8c0c..bb1e2aaaa9ba 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
DestroyOneResolverArgs,
@@ -27,13 +28,17 @@ export class DestroyOneResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.graphQLQueryRunnerService.destroyOne(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ return await this.graphQLQueryRunnerService.destroyOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts
index 0a1494efb666..f8b57ad22cca 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
FindDuplicatesResolverArgs,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class FindDuplicatesResolverFactory
@@ -18,6 +22,8 @@ export class FindDuplicatesResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,33 @@ export class FindDuplicatesResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.findDuplicates(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.findDuplicates(
+ args,
+ options,
+ );
+ }
+
+ return await this.workspaceQueryRunnerService.findDuplicates(
+ args,
+ options,
+ );
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts
index 2dd452a2976c..c695079e2f62 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
FindManyResolverArgs,
@@ -27,12 +28,14 @@ export class FindManyResolverFactory
return async (_source, args, _context, info) => {
try {
- const options = {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
};
return await this.graphqlQueryRunnerService.findMany(args, options);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts
index 3dbbc2330d06..00845e841710 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
FindOneResolverArgs,
@@ -27,12 +28,14 @@ export class FindOneResolverFactory
return async (_source, args, _context, info) => {
try {
- const options = {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
};
return await this.graphqlQueryRunnerService.findOne(args, options);
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts
index ceba95306aed..d92210040535 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
Resolver,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class RestoreManyResolverFactory
@@ -18,6 +22,8 @@ export class RestoreManyResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,33 @@ export class RestoreManyResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.restoreMany(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.restoreMany(
+ args,
+ options,
+ );
+ }
+
+ return await this.workspaceQueryRunnerService.restoreMany(
+ args,
+ options,
+ );
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts
index 5b32d527960f..9d559b656194 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
Resolver,
@@ -25,13 +26,17 @@ export class SearchResolverFactory
return async (_source, args, _context, info) => {
try {
- return await this.graphqlQueryRunnerService.search(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ return await this.graphqlQueryRunnerService.search(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts
index c2328d12ba05..11027e4cc4fd 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
Resolver,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class UpdateManyResolverFactory
@@ -18,6 +22,8 @@ export class UpdateManyResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,27 @@ export class UpdateManyResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.updateMany(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.updateMany(args, options);
+ }
+
+ return await this.workspaceQueryRunnerService.updateMany(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts
index c7a7dc6bacd6..13a2e4f714d1 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/update-one-resolver.factory.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
import {
Resolver,
@@ -7,8 +8,11 @@ import {
} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+import { GraphqlQueryRunnerService } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-runner.service';
import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util';
import { WorkspaceQueryRunnerService } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.service';
+import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
+import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class UpdateOneResolverFactory
@@ -18,6 +22,8 @@ export class UpdateOneResolverFactory
constructor(
private readonly workspaceQueryRunnerService: WorkspaceQueryRunnerService,
+ private readonly featureFlagService: FeatureFlagService,
+ private readonly graphqlQueryRunnerService: GraphqlQueryRunnerService,
) {}
create(
@@ -27,13 +33,27 @@ export class UpdateOneResolverFactory
return async (_source, args, context, info) => {
try {
- return await this.workspaceQueryRunnerService.updateOne(args, {
+ const options: WorkspaceQueryRunnerOptions = {
authContext: internalContext.authContext,
objectMetadataItem: internalContext.objectMetadataItem,
info,
fieldMetadataCollection: internalContext.fieldMetadataCollection,
objectMetadataCollection: internalContext.objectMetadataCollection,
- });
+ objectMetadataMap: internalContext.objectMetadataMap,
+ objectMetadataMapItem: internalContext.objectMetadataMapItem,
+ };
+
+ const isQueryRunnerTwentyORMEnabled =
+ await this.featureFlagService.isFeatureEnabled(
+ FeatureFlagKey.IsQueryRunnerTwentyORMEnabled,
+ internalContext.authContext.workspace.id,
+ );
+
+ if (isQueryRunnerTwentyORMEnabled) {
+ return await this.graphqlQueryRunnerService.updateOne(args, options);
+ }
+
+ return await this.workspaceQueryRunnerService.updateOne(args, options);
} catch (error) {
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
index 616c734581df..a652e3065c81 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
@@ -11,6 +11,7 @@ import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-res
import { SearchResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/search-resolver-factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { ObjectMetadataMap } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
import { getResolverName } from 'src/engine/utils/get-resolver-name.util';
import { CreateManyResolverFactory } from './factories/create-many-resolver.factory';
@@ -49,6 +50,7 @@ export class WorkspaceResolverFactory {
async create(
authContext: AuthContext,
objectMetadataCollection: ObjectMetadataInterface[],
+ objectMetadataMap: ObjectMetadataMap,
workspaceResolverBuilderMethods: WorkspaceResolverBuilderMethods,
): Promise {
const factories = new Map<
@@ -94,7 +96,9 @@ export class WorkspaceResolverFactory {
authContext,
objectMetadataItem: objectMetadata,
fieldMetadataCollection: objectMetadata.fields,
- objectMetadataCollection: objectMetadataCollection,
+ objectMetadataCollection,
+ objectMetadataMap,
+ objectMetadataMapItem: objectMetadataMap[objectMetadata.nameSingular],
});
}
@@ -117,7 +121,9 @@ export class WorkspaceResolverFactory {
authContext,
objectMetadataItem: objectMetadata,
fieldMetadataCollection: objectMetadata.fields,
- objectMetadataCollection: objectMetadataCollection,
+ objectMetadataCollection,
+ objectMetadataMap,
+ objectMetadataMapItem: objectMetadataMap[objectMetadata.nameSingular],
});
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
index f5a6aec8b1a2..d0ab66983309 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface.ts
@@ -2,10 +2,16 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada
import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
+import {
+ ObjectMetadataMap,
+ ObjectMetadataMapItem,
+} from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
export interface WorkspaceSchemaBuilderContext {
authContext: AuthContext;
- objectMetadataItem: ObjectMetadataInterface;
fieldMetadataCollection: FieldMetadataInterface[];
objectMetadataCollection: ObjectMetadataInterface[];
+ objectMetadataItem: ObjectMetadataInterface;
+ objectMetadataMap: ObjectMetadataMap;
+ objectMetadataMapItem: ObjectMetadataMapItem;
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
index 336fa825f81c..32a44ad4d26e 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema.factory.ts
@@ -117,6 +117,7 @@ export class WorkspaceSchemaFactory {
const autoGeneratedResolvers = await this.workspaceResolverFactory.create(
authContext,
objectMetadataCollection,
+ objectMetadataMap,
workspaceResolverBuilderMethodNames,
);
const scalarsResolvers =
diff --git a/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts b/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts
index d7ed6f87bd85..48d021b5e689 100644
--- a/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/duplicate/duplicate.service.ts
@@ -1,15 +1,15 @@
import { Injectable } from '@nestjs/common';
-import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import {
Record as IRecord,
Record,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
+import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/object-metadata.interface';
import { settings } from 'src/engine/constants/settings';
+import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
-import { DUPLICATE_CRITERIA_COLLECTION } from 'src/engine/core-modules/duplicate/constants/duplicate-criteria.constants';
@Injectable()
export class DuplicateService {
@@ -94,80 +94,4 @@ export class DuplicateService {
duplicateCriteria.objectName === objectMetadataItem.nameSingular,
);
}
-
- /**
- * TODO: Remove this code by September 1st, 2024 if it isn't used
- * It was build to be used by the upsertMany function, but it was not used.
- * It's a re-implementation of the methods to findDuplicates, but done
- * at the SQL layer instead of doing it at the GraphQL layer
- *
- async findDuplicate(
- data: Partial,
- objectMetadata: ObjectMetadataInterface,
- workspaceId: string,
- ) {
- const dataSourceSchema =
- this.workspaceDataSourceService.getSchemaName(workspaceId);
-
- const { duplicateWhereClause, duplicateWhereParameters } =
- this.buildDuplicateConditionForUpsert(objectMetadata, data);
-
- const results = await this.workspaceDataSourceService.executeRawQuery(
- `
- SELECT
- *
- FROM
- ${dataSourceSchema}."${computeObjectTargetTable(
- objectMetadata,
- )}" p
- WHERE
- ${duplicateWhereClause}
- `,
- duplicateWhereParameters,
- workspaceId,
- );
-
- return results.length > 0 ? results[0] : null;
- }
-
- private buildDuplicateConditionForUpsert(
- objectMetadata: ObjectMetadataInterface,
- data: Partial,
- ) {
- const criteriaCollection = this.getApplicableDuplicateCriteriaCollection(
- objectMetadata,
- ).filter(
- (duplicateCriteria) => duplicateCriteria.useAsUniqueKeyForUpsert === true,
- );
-
- const whereClauses: string[] = [];
- const whereParameters: any[] = [];
- let parameterIndex = 1;
-
- criteriaCollection.forEach((c) => {
- const clauseParts: string[] = [];
-
- c.columnNames.forEach((column) => {
- const dataKey = Object.keys(data).find(
- (key) => key.toLowerCase() === column.toLowerCase(),
- );
-
- if (dataKey) {
- clauseParts.push(`p."${column}" = $${parameterIndex}`);
- whereParameters.push(data[dataKey]);
- parameterIndex++;
- }
- });
- if (clauseParts.length > 0) {
- whereClauses.push(`(${clauseParts.join(' AND ')})`);
- }
- });
-
- const duplicateWhereClause = whereClauses.join(' OR ');
- const duplicateWhereParameters = whereParameters;
-
- return { duplicateWhereClause, duplicateWhereParameters };
- }
- *
- */
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts
index 7e44586d00eb..07471ac9b2da 100644
--- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts
@@ -60,8 +60,8 @@ export class IndexMetadataEntity {
@Column({
type: 'enum',
enum: IndexType,
- nullable: true,
default: IndexType.BTREE,
+ nullable: false,
})
indexType?: IndexType;
}
diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts
index f6f31f32c9db..cd3851393678 100644
--- a/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-data.util.ts
@@ -1,9 +1,11 @@
-import { isPlainObject } from '@nestjs/common/utils/shared.utils';
+import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
-import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
+import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
+import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataMapItem } from 'src/engine/metadata-modules/utils/generate-object-metadata-map.util';
-import { getCompositeFieldMetadataCollection } from 'src/engine/twenty-orm/utils/get-composite-field-metadata-collection';
+import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory';
+import { capitalize } from 'src/utils/capitalize';
export function formatData(
data: T,
@@ -17,49 +19,70 @@ export function formatData(
return data.map((item) => formatData(item, objectMetadata)) as T;
}
- const compositeFieldMetadataCollection =
- getCompositeFieldMetadataCollection(objectMetadata);
-
- const compositeFieldMetadataMap = new Map(
- compositeFieldMetadataCollection.map((fieldMetadata) => [
- fieldMetadata.name,
- fieldMetadata,
- ]),
- );
- const newData: object = {};
+ const newData: Record = {};
for (const [key, value] of Object.entries(data)) {
- const fieldMetadata = compositeFieldMetadataMap.get(key);
+ const fieldMetadata = objectMetadata.fields[key];
if (!fieldMetadata) {
- if (isPlainObject(value)) {
- newData[key] = formatData(value, objectMetadata);
- } else {
- newData[key] = value;
- }
- continue;
+ throw new Error(
+ `Field metadata for field "${key}" is missing in object metadata`,
+ );
}
- const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
+ if (isCompositeFieldMetadataType(fieldMetadata.type)) {
+ const formattedCompositeField = formatCompositeField(
+ value,
+ fieldMetadata,
+ );
- if (!compositeType) {
- continue;
+ Object.assign(newData, formattedCompositeField);
+ } else {
+ newData[key] = formatFieldMetadataValue(value, fieldMetadata);
}
+ }
- for (const compositeProperty of compositeType.properties) {
- const compositeKey = computeCompositeColumnName(
- fieldMetadata.name,
- compositeProperty,
- );
- const value = data?.[key]?.[compositeProperty.name];
+ return newData as T;
+}
- if (value === undefined || value === null) {
- continue;
- }
+function formatCompositeField(
+ value: any,
+ fieldMetadata: FieldMetadataInterface,
+): Record {
+ const compositeType = compositeTypeDefinitions.get(
+ fieldMetadata.type as CompositeFieldMetadataType,
+ );
- newData[compositeKey] = data[key][compositeProperty.name];
+ if (!compositeType) {
+ throw new Error(
+ `Composite type definition not found for type: ${fieldMetadata.type}`,
+ );
+ }
+
+ const formattedCompositeField: Record = {};
+
+ for (const property of compositeType.properties) {
+ const subFieldKey = property.name;
+ const fullFieldName = `${fieldMetadata.name}${capitalize(subFieldKey)}`;
+
+ if (value && value[subFieldKey] !== undefined) {
+ formattedCompositeField[fullFieldName] = formatFieldMetadataValue(
+ value[subFieldKey],
+ property as unknown as FieldMetadataInterface,
+ );
}
}
- return newData as T;
+ return formattedCompositeField;
+}
+
+function formatFieldMetadataValue(
+ value: any,
+ fieldMetadata: FieldMetadataInterface,
+) {
+ if (fieldMetadata.type === FieldMetadataType.RAW_JSON) {
+ return JSON.parse(value as string);
+ }
+
+ return value;
}
diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
index 81949e0743a3..2b29ea908cd8 100644
--- a/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts
@@ -1,6 +1,9 @@
import { isPlainObject } from '@nestjs/common/utils/shared.utils';
+import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
+
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
+import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import {
@@ -81,9 +84,15 @@ export function formatResult(
if (!compositePropertyArgs && !relationMetadata) {
if (isPlainObject(value)) {
newData[key] = formatResult(value, objectMetadata, objectMetadataMap);
+ } else if (objectMetadata.fields[key]) {
+ newData[key] = formatFieldMetadataValue(
+ value,
+ objectMetadata.fields[key],
+ );
} else {
newData[key] = value;
}
+
continue;
}
@@ -129,3 +138,18 @@ export function formatResult(
return newData as T;
}
+
+function formatFieldMetadataValue(
+ value: any,
+ fieldMetadata: FieldMetadataInterface,
+) {
+ if (
+ typeof value === 'string' &&
+ (fieldMetadata.type === FieldMetadataType.MULTI_SELECT ||
+ fieldMetadata.type === FieldMetadataType.ARRAY)
+ ) {
+ return value.replace(/{|}/g, '').split(',');
+ }
+
+ return value;
+}
From 424c4890b00b0bab59b6b7754b4e191bfdce8217 Mon Sep 17 00:00:00 2001
From: Harshit Singh <73997189+harshit078@users.noreply.github.com>
Date: Fri, 4 Oct 2024 20:08:15 +0530
Subject: [PATCH 06/16] fix: New Relation Design hot fix (#7423)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
- This PR solves the issue #7353
- [x] Improved layout for mobile and desktop
- [ ] Added tooltip on hover
---------
Co-authored-by: Nitin Koche
Co-authored-by: Félix Malfait
---
.../SettingsDataModelFieldRelationForm.tsx | 10 ++++----
...DataModelFieldRelationSettingsFormCard.tsx | 24 +++++++++++++------
2 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
index 83f81326436a..d6f3fe48be10 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/relation/components/SettingsDataModelFieldRelationForm.tsx
@@ -14,6 +14,7 @@ import { RelationType } from '@/settings/data-model/types/RelationType';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
+import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { RelationDefinitionType } from '~/generated-metadata/graphql';
export const settingsDataModelFieldRelationFormSchema = z.object({
@@ -44,13 +45,12 @@ const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
`;
-const StyledSelectsContainer = styled.div`
+const StyledSelectsContainer = styled.div<{ isMobile: boolean }>`
display: grid;
gap: ${({ theme }) => theme.spacing(4)};
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: ${({ isMobile }) => (isMobile ? '1fr' : '1fr 1fr')};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
-
const StyledInputsLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
@@ -98,9 +98,11 @@ export const SettingsDataModelFieldRelationForm = ({
watchFormValue('relation.objectMetadataId'),
);
+ const isMobile = useIsMobile();
+
return (
-
+
&
Partial>;
@@ -27,14 +27,23 @@ const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)`
flex: 1 1 100%;
`;
-const StyledPreviewContent = styled.div`
+const StyledPreviewContent = styled.div<{ isMobile: boolean }>`
display: flex;
- flex-direction: column;
gap: 6px;
+ flex-direction: ${({ isMobile }) => (isMobile ? 'column' : 'row')};
`;
-const StyledRelationImage = styled.img<{ flip?: boolean }>`
- transform: ${({ flip }) => (flip ? 'scaleX(-1) rotate(270deg)' : 'none')};
+const StyledRelationImage = styled.img<{ flip?: boolean; isMobile: boolean }>`
+ transform: ${({ flip, isMobile }) => {
+ let transform = '';
+ if (isMobile) {
+ transform += 'rotate(90deg) ';
+ }
+ if (flip === true) {
+ transform += 'scaleX(-1)';
+ }
+ return transform.trim();
+ }};
margin: auto;
width: 54px;
`;
@@ -46,7 +55,7 @@ export const SettingsDataModelFieldRelationSettingsFormCard = ({
const { watch: watchFormValue } =
useFormContext();
const { findObjectMetadataItemById } = useFilteredObjectMetadataItems();
-
+ const isMobile = useIsMobile();
const {
initialRelationObjectMetadataItem,
initialRelationType,
@@ -69,7 +78,7 @@ export const SettingsDataModelFieldRelationSettingsFormCard = ({
return (
+
Date: Fri, 4 Oct 2024 20:47:05 +0530
Subject: [PATCH 07/16] oss.gg:quest points tracking files/folders (#7430)
### create all oss.gg quest tracking file which oss.gg hackathon
contributor come and there proof of work
### let me know if any changes required :slightly_smiling_face: also can
I get some brownie oss.gg point ? for this PR
### thank you





---
.../1-create-youtube-video-about-20.md | 21 +++++++++++++++++
.../2-write-blog-post-about-20.md | 21 +++++++++++++++++
.../3-write-selfthost-guide-blog-post-20.md | 21 +++++++++++++++++
.../4-create-promotional-video-20-share.md | 21 +++++++++++++++++
.../1-design-promotional-poster-20-share.md | 21 +++++++++++++++++
.../2-design-new-logo-twenty.md | 21 +++++++++++++++++
.../3-create-custom-interfact-theme-20.md | 21 +++++++++++++++++
...-write-migration-script-other-crm-to-20.md | 21 +++++++++++++++++
.../2-create-raycast-integration-for-20.md | 21 +++++++++++++++++
.../1-create-n8n-template-integrate-20-API.md | 21 +++++++++++++++++
.../2-write-selfthost-guide-blog-post-20.md | 21 +++++++++++++++++
.../1-quote-tweet-20-oss-gg-launch.md | 23 +++++++++++++++++++
.../2-tweet-about-fav-twenty-feature.md | 23 +++++++++++++++++++
oss-gg/twenty-side-quest/3-meme-magic.md | 23 +++++++++++++++++++
oss-gg/twenty-side-quest/4-gif-magic.md | 23 +++++++++++++++++++
oss-gg/twenty-side-quest/5-quest-wizard.md | 19 +++++++++++++++
16 files changed, 342 insertions(+)
create mode 100644 oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md
create mode 100644 oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md
create mode 100644 oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md
create mode 100644 oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md
create mode 100644 oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md
create mode 100644 oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md
create mode 100644 oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md
create mode 100644 oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md
create mode 100644 oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md
create mode 100644 oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md
create mode 100644 oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md
create mode 100644 oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md
create mode 100644 oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md
create mode 100644 oss-gg/twenty-side-quest/3-meme-magic.md
create mode 100644 oss-gg/twenty-side-quest/4-gif-magic.md
create mode 100644 oss-gg/twenty-side-quest/5-quest-wizard.md
diff --git a/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md b/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md
new file mode 100644
index 000000000000..455b5e35bae3
--- /dev/null
+++ b/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Create a YouTube Video about Twenty showcasing a specific way to use Twenty effectively.
+**Points**: 750 Points
+**Proof**: Add your oss handle and YouTube video link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » YouTube Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) YouTube Link: [YouTube](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md b/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md
new file mode 100644
index 000000000000..a4c4e6bee944
--- /dev/null
+++ b/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Write a blog post about sharing your experience using Twenty in a detailed format on any platform.
+**Points**: 750 Points
+**Proof**: Add your oss handle and blog link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » blog Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) blog Link: [blog](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md b/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md
new file mode 100644
index 000000000000..c7352ec430fc
--- /dev/null
+++ b/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Write a blog post about self-hosting Twenty in a detailed format on any platform.
+**Points**: 750 Points
+**Proof**: Add your oss handle and blog link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » blog Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) blog Link: [blog](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md b/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md
new file mode 100644
index 000000000000..e52cb43a4247
--- /dev/null
+++ b/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md
@@ -0,0 +1,21 @@
+**Side Quest**: Create a promotional video for Twenty and share it on social media.
+**Points**: 750 Points
+**Proof**: Add your oss handle and video link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md b/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md
new file mode 100644
index 000000000000..b995788bc3f1
--- /dev/null
+++ b/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md
@@ -0,0 +1,21 @@
+**Side Quest**: Design a promotional poster of Twenty and share it on social media.
+**Points**: 300 Points
+**Proof**: Add your oss handle and poster link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » poster Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) poster Link: [poster](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md b/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md
new file mode 100644
index 000000000000..e8e70ca913cb
--- /dev/null
+++ b/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md
@@ -0,0 +1,21 @@
+**Side Quest**: Design/Create new Twenty logo, tweet your design, and mention @twentycrm.
+**Points**: 300 Points
+**Proof**: Create a logo uploade it on any of the platform and add your oss handle and logo link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » Logo Link: https://link.to/content » tweet Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) Logo Link: [logo](https://twenty.com/) tweet Link: [tweet](https://x.com)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md b/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md
new file mode 100644
index 000000000000..e51945ea9988
--- /dev/null
+++ b/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Duplicate the Figma file from the main repo and customize the variables to create a unique interface theme for Twenty.
+**Points**: 750 Points
+**Proof**: Add your oss handle and Figma link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » Figma Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) Figma Link: [Figma](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md b/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md
new file mode 100644
index 000000000000..249d8e158cfa
--- /dev/null
+++ b/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Develop a script to facilitate the migration of data from another CRM to Twenty.
+**Points**: 750 Points
+**Proof**: Add your oss handle and record video and share link to the list below. In video show the working proof of your created script.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md b/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md
new file mode 100644
index 000000000000..e4793c40d66f
--- /dev/null
+++ b/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Develop an integration for Raycast that enables users to create records on any object within Twenty directly from Raycast.
+**Points**: 1500 Points
+**Proof**: Add your oss handle and record video and share link to the list below. In video show the workflow of the your integration created and perform some task.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md b/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md
new file mode 100644
index 000000000000..6786e5a94553
--- /dev/null
+++ b/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md
@@ -0,0 +1,21 @@
+**Side Quest**: Create an n8n workflow that empowers Twenty by connecting it to another tool.
+**Points**: 750 Points
+**Proof**: Add your oss handle and template link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » template Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) template Link: [template](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md b/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md
new file mode 100644
index 000000000000..58fa6de4d8d6
--- /dev/null
+++ b/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md
@@ -0,0 +1,21 @@
+**Side Quest**: Write a comprehensive guide on how to integrate Twenty with marketing automation tool (n8n, Zapier). Include a concrete use case and explain how to leverage AI to write API requests for non-developers and share it.
+**Points**: 1500 Points
+**Proof**: Add your oss handle and guide link to the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR oss.gg HANDLE » guide Link: https://link.to/content
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) guide Link: [guide](https://twenty.com/)
+
+---
\ No newline at end of file
diff --git a/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md b/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md
new file mode 100644
index 000000000000..0a33e2287146
--- /dev/null
+++ b/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md
@@ -0,0 +1,23 @@
+**Side Quest**: Meme Magic - Craft a meme where a brick plays a role. Tweet it, and tag us @papermarkio to submit.
+**Points**: 150 Points
+**Proof**: Add a screenshot of meme to the PR description. Add a link to your tweet in the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 01-October-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
diff --git a/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md b/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md
new file mode 100644
index 000000000000..0a33e2287146
--- /dev/null
+++ b/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md
@@ -0,0 +1,23 @@
+**Side Quest**: Meme Magic - Craft a meme where a brick plays a role. Tweet it, and tag us @papermarkio to submit.
+**Points**: 150 Points
+**Proof**: Add a screenshot of meme to the PR description. Add a link to your tweet in the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 01-October-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
diff --git a/oss-gg/twenty-side-quest/3-meme-magic.md b/oss-gg/twenty-side-quest/3-meme-magic.md
new file mode 100644
index 000000000000..0a33e2287146
--- /dev/null
+++ b/oss-gg/twenty-side-quest/3-meme-magic.md
@@ -0,0 +1,23 @@
+**Side Quest**: Meme Magic - Craft a meme where a brick plays a role. Tweet it, and tag us @papermarkio to submit.
+**Points**: 150 Points
+**Proof**: Add a screenshot of meme to the PR description. Add a link to your tweet in the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 01-October-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
diff --git a/oss-gg/twenty-side-quest/4-gif-magic.md b/oss-gg/twenty-side-quest/4-gif-magic.md
new file mode 100644
index 000000000000..0e38ace584d6
--- /dev/null
+++ b/oss-gg/twenty-side-quest/4-gif-magic.md
@@ -0,0 +1,23 @@
+**Side Quest**: GIF Magic - Craft a GIF where a brick plays a role. Upload it to GIPHY with tags 'open source', 'foss', 'papermarkio'.
+**Points**: 150 Points
+**Proof**: Add a screenshot of GIF on Giphy to the PR description. Add a link to your GIPHY in the list below.
+
+Please follow the following schema:
+
+---
+
+» 05-April-2024 by YOUR NAME
+» Link to Tweet: https://giphy.com/...
+
+---
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 01-October-2024 by YOUR NAME
+» Link to Tweet: https://x.com/...
+
+---
diff --git a/oss-gg/twenty-side-quest/5-quest-wizard.md b/oss-gg/twenty-side-quest/5-quest-wizard.md
new file mode 100644
index 000000000000..2dfe4bd9c86b
--- /dev/null
+++ b/oss-gg/twenty-side-quest/5-quest-wizard.md
@@ -0,0 +1,19 @@
+**Side Quest**: Complete all papermarkio side quests
+**Points**: 300 Points
+**Proof**: Add screenshots for each side quest to the PR description. Add your name to the list below.
+
+Please follow the following schema:
+
+---
+
+ » 05-April-2024 by YOUR NAME
+
+////////////////////////////
+
+Your turn 👇
+
+////////////////////////////
+
+» 01-October-2024 by X
+
+---
From 2f223f329461d9e7e6ba360a44b6876928674c59 Mon Sep 17 00:00:00 2001
From: Weiko
Date: Fri, 4 Oct 2024 18:31:19 +0200
Subject: [PATCH 08/16] Fix 'name' column wrongly added in standard objects
(#7428)
## Context
Name shouldn't be added to all tables, especially standard objects
because they already have their own labelIdentifierFieldMetadata
specified in the workspace-entity schema. This PR removes this column
from the "base" list of columns to add when creating a new object/table
and moves it to the object-metadata service that is, as of today, only
used for custom objects. Also had to modify the migration-runner to
handle column creation in a table creation migration (this was available
in the migration definition already but was not doing anything)
This also fixes an issue in standard objects that already have a "name"
field defined with a different field type, this is even more important
when the said field is a composite field. For example people already has
a FULL_NAME name field which clashes with the default TEXT name field
meaning it was only creating 1 field metadata for 'name' but 3 columns
were created: `name, nameFirstName, nameLastName`. This inconsistency
with metadata (which is our source of truth everywhere) brought some
issues (lately, converting back typeorm response to gql (including
composition) was broken).
---
...phql-query-update-many-resolver.service.ts | 3 +-
...aphql-query-update-one-resolver.service.ts | 8 +--
.../object-metadata.service.ts | 39 ++++++++++++-
...ations-for-custom-object-relations.util.ts | 4 --
.../twenty-orm/base.workspace-entity.ts | 2 +-
.../utils/custom-table-default-column.util.ts | 55 -------------------
.../utils/table-default-column.util.ts | 10 ++++
.../workspace-migration-runner.service.ts | 21 ++++++-
.../calendar/common/types/calendar-event.ts | 2 +
.../message-import-manager/types/message.ts | 2 +
10 files changed, 74 insertions(+), 72 deletions(-)
delete mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/custom-table-default-column.util.ts
create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util.ts
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
index 149674209329..d8854223794b 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service.ts
@@ -66,8 +66,7 @@ export class GraphqlQueryUpdateManyResolverService
const data = formatData(args.data, objectMetadataMapItem);
const result = await withFilterQueryBuilder
- .update()
- .set(data)
+ .update(data)
.returning('*')
.execute();
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
index 7be966cc656c..6fc4e1a72c58 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-one-resolver.service.ts
@@ -61,13 +61,11 @@ export class GraphqlQueryUpdateOneResolverService
objectMetadataMapItem.nameSingular,
);
- const withFilterQueryBuilder = queryBuilder.where({ id: args.id });
-
const data = formatData(args.data, objectMetadataMapItem);
- const result = await withFilterQueryBuilder
- .update()
- .set(data)
+ const result = await queryBuilder
+ .update(data)
+ .where({ id: args.id })
.returning('*')
.execute();
diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
index b4102db85746..ff08237d20fc 100644
--- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts
@@ -49,8 +49,10 @@ import { generateMigrationName } from 'src/engine/metadata-modules/workspace-mig
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnDrop,
+ WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
+import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
@@ -103,6 +105,7 @@ export class ObjectMetadataService extends TypeOrmQueryService [
- {
- name: computeObjectTargetTable(createdObjectMetadata),
- action: WorkspaceMigrationTableActionType.CREATE,
- } satisfies WorkspaceMigrationTableAction,
// Add activity target relation
{
name: computeObjectTargetTable(activityTargetObjectMetadata),
diff --git a/packages/twenty-server/src/engine/twenty-orm/base.workspace-entity.ts b/packages/twenty-server/src/engine/twenty-orm/base.workspace-entity.ts
index d9a19a689301..53355304c346 100644
--- a/packages/twenty-server/src/engine/twenty-orm/base.workspace-entity.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/base.workspace-entity.ts
@@ -55,5 +55,5 @@ export abstract class BaseWorkspaceEntity {
},
})
@WorkspaceIsNullable()
- deletedAt?: string | null;
+ deletedAt: string | null;
}
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/custom-table-default-column.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/custom-table-default-column.util.ts
deleted file mode 100644
index 11a831f809d4..000000000000
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/custom-table-default-column.util.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { TableColumnOptions } from 'typeorm';
-
-import { FieldActorSource } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
-
-export const customTableDefaultColumns = (
- tableName: string,
-): TableColumnOptions[] => [
- {
- name: 'id',
- type: 'uuid',
- isPrimary: true,
- default: 'public.uuid_generate_v4()',
- },
- {
- name: 'createdAt',
- type: 'timestamptz',
- default: 'now()',
- },
- {
- name: 'updatedAt',
- type: 'timestamptz',
- default: 'now()',
- },
- {
- name: 'deletedAt',
- type: 'timestamptz',
- isNullable: true,
- },
- {
- name: 'position',
- type: 'float',
- isNullable: true,
- },
- {
- name: 'name',
- type: 'text',
- isNullable: false,
- default: "'Untitled'",
- },
- {
- name: 'createdBySource',
- type: 'enum',
- enumName: `${tableName}_createdBySource_enum`,
- enum: Object.values(FieldActorSource),
- isNullable: false,
- default: `'${FieldActorSource.MANUAL}'`,
- },
- { name: 'createdByWorkspaceMemberId', type: 'uuid', isNullable: true },
- {
- name: 'createdByName',
- type: 'text',
- isNullable: false,
- default: "''",
- },
-];
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util.ts
new file mode 100644
index 000000000000..8967eb83f0d9
--- /dev/null
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util.ts
@@ -0,0 +1,10 @@
+import { TableColumnOptions } from 'typeorm';
+
+export const tableDefaultColumns = (): TableColumnOptions[] => [
+ {
+ name: 'id',
+ type: 'uuid',
+ isPrimary: true,
+ default: 'public.uuid_generate_v4()',
+ },
+];
diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts
index 2d6a20e147e1..db2072c8447b 100644
--- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts
+++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts
@@ -26,10 +26,10 @@ import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { WorkspaceMigrationEnumService } from 'src/engine/workspace-manager/workspace-migration-runner/services/workspace-migration-enum.service';
import { convertOnDeleteActionToOnDelete } from 'src/engine/workspace-manager/workspace-migration-runner/utils/convert-on-delete-action-to-on-delete.util';
+import { tableDefaultColumns } from 'src/engine/workspace-manager/workspace-migration-runner/utils/table-default-column.util';
import { isDefined } from 'src/utils/is-defined';
import { WorkspaceMigrationTypeService } from './services/workspace-migration-type.service';
-import { customTableDefaultColumns } from './utils/custom-table-default-column.util';
@Injectable()
export class WorkspaceMigrationRunnerService {
@@ -121,7 +121,12 @@ export class WorkspaceMigrationRunnerService {
) {
switch (tableMigration.action) {
case WorkspaceMigrationTableActionType.CREATE:
- await this.createTable(queryRunner, schemaName, tableMigration.name);
+ await this.createTable(
+ queryRunner,
+ schemaName,
+ tableMigration.name,
+ tableMigration.columns,
+ );
break;
case WorkspaceMigrationTableActionType.ALTER: {
if (tableMigration.newName) {
@@ -244,16 +249,26 @@ export class WorkspaceMigrationRunnerService {
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
+ columns?: WorkspaceMigrationColumnAction[],
) {
await queryRunner.createTable(
new Table({
name: tableName,
schema: schemaName,
- columns: customTableDefaultColumns(tableName),
+ columns: tableDefaultColumns(),
}),
true,
);
+ if (columns && columns.length > 0) {
+ await this.handleColumnChanges(
+ queryRunner,
+ schemaName,
+ tableName,
+ columns,
+ );
+ }
+
// Enable totalCount for the table
await queryRunner.query(`
COMMENT ON TABLE "${schemaName}"."${tableName}" IS '@graphql({"totalCount": {"enabled": true}})';
diff --git a/packages/twenty-server/src/modules/calendar/common/types/calendar-event.ts b/packages/twenty-server/src/modules/calendar/common/types/calendar-event.ts
index 00f4c82ac5c5..ae817cc3fae7 100644
--- a/packages/twenty-server/src/modules/calendar/common/types/calendar-event.ts
+++ b/packages/twenty-server/src/modules/calendar/common/types/calendar-event.ts
@@ -5,6 +5,7 @@ export type CalendarEvent = Omit<
CalendarEventWorkspaceEntity,
| 'createdAt'
| 'updatedAt'
+ | 'deletedAt'
| 'calendarChannelEventAssociations'
| 'calendarEventParticipants'
| 'conferenceLink'
@@ -19,6 +20,7 @@ export type CalendarEventParticipant = Omit<
| 'id'
| 'createdAt'
| 'updatedAt'
+ | 'deletedAt'
| 'personId'
| 'workspaceMemberId'
| 'person'
diff --git a/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts b/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
index 4ec483869a8d..b665e54898a8 100644
--- a/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
+++ b/packages/twenty-server/src/modules/messaging/message-import-manager/types/message.ts
@@ -6,6 +6,7 @@ export type Message = Omit<
MessageWorkspaceEntity,
| 'createdAt'
| 'updatedAt'
+ | 'deletedAt'
| 'messageChannelMessageAssociations'
| 'messageParticipants'
| 'messageThread'
@@ -25,6 +26,7 @@ export type MessageParticipant = Omit<
| 'id'
| 'createdAt'
| 'updatedAt'
+ | 'deletedAt'
| 'personId'
| 'workspaceMemberId'
| 'person'
From ae2bd66f45f1079564c92085347c46e6ec992100 Mon Sep 17 00:00:00 2001
From: bmbaji <116686135+bmbaji@users.noreply.github.com>
Date: Fri, 4 Oct 2024 12:57:15 -0400
Subject: [PATCH 09/16] changed the createdByName to Twenty(Sample data).
(#7424)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
I changed the createdByName from' system' to 'Twenty(Sample Data)'.
---------
Co-authored-by: Félix Malfait
---
.../utils/getSourceEnumOptions.ts | 8 ++++++++
.../ui/field/display/components/ActorDisplay.tsx | 6 +++++-
.../composite-types/actor.composite-type.ts | 1 +
.../standard-objects-prefill-data/company.ts | 12 +++++++-----
.../standard-objects-prefill-data/person.ts | 11 ++++++-----
.../src/display/icon/components/TablerIcons.ts | 7 ++++---
6 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSourceEnumOptions.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSourceEnumOptions.ts
index ee886da633e0..7006bb1ed8f1 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSourceEnumOptions.ts
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSourceEnumOptions.ts
@@ -4,6 +4,7 @@ import {
IconCsv,
IconGmail,
IconGoogleCalendar,
+ IconRobot,
IconSettingsAutomation,
IconUserCircle,
} from 'twenty-ui';
@@ -52,5 +53,12 @@ export const getSourceEnumOptions = (
AvatarIcon: IconSettingsAutomation,
isIconInverted: true,
},
+ {
+ id: 'SYSTEM',
+ name: 'System',
+ isSelected: selectedItemIds.includes('SYSTEM'),
+ AvatarIcon: IconRobot,
+ isIconInverted: true,
+ },
];
};
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
index ba107aa612f0..b5111d245a0a 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
@@ -7,6 +7,7 @@ import {
IconCalendar,
IconCsv,
IconGmail,
+ IconRobot,
} from 'twenty-ui';
type ActorDisplayProps = Partial & {
@@ -29,12 +30,15 @@ export const ActorDisplay = ({
return IconGmail;
case 'CALENDAR':
return IconCalendar;
+ case 'SYSTEM':
+ return IconRobot;
default:
return undefined;
}
}, [source]);
- const isIconInverted = source === 'API' || source === 'IMPORT';
+ const isIconInverted =
+ source === 'API' || source === 'IMPORT' || source === 'SYSTEM';
return (
Date: Fri, 4 Oct 2024 19:08:08 +0200
Subject: [PATCH 10/16] Adding instruction for running redis (#7434)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fixes #7433
---------
Co-authored-by: Félix Malfait
---
.../src/content/developers/local-setup.mdx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/packages/twenty-website/src/content/developers/local-setup.mdx b/packages/twenty-website/src/content/developers/local-setup.mdx
index 7e436c1aa304..69cf9cb630e7 100644
--- a/packages/twenty-website/src/content/developers/local-setup.mdx
+++ b/packages/twenty-website/src/content/developers/local-setup.mdx
@@ -185,6 +185,24 @@ yarn
## Step 7: Running the project
+Start your redis server:
+
+
+ Depending on your Linux distribution, Redis server might be started automatically.
+ If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
+
+
+ ```bash
+ brew services start redis
+ ```
+
+
+ Depending on your Linux distribution, Redis server might be started automatically.
+ If not, check the [Redis installation guide](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) for your distro.
+
+
+
+
Setup your database with the following command:
```bash
npx nx database:reset twenty-server
From bd305c84320fce442b6237a02c5a4f8e84452fac Mon Sep 17 00:00:00 2001
From: Weiko
Date: Fri, 4 Oct 2024 19:28:29 +0200
Subject: [PATCH 11/16] Fix worker run ci step (#7437)
## Context
Updating the Worker / Run step to run in sync mode with in-memory cache
type so it does not hang forever in the CI.
---
.github/workflows/ci-server.yaml | 2 +-
packages/twenty-server/project.json | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml
index 857dec2fc863..074d63fdda40 100644
--- a/.github/workflows/ci-server.yaml
+++ b/.github/workflows/ci-server.yaml
@@ -54,7 +54,7 @@ jobs:
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Worker / Run
- run: MESSAGE_QUEUE_TYPE=sync npx nx worker twenty-server
+ run: npx nx run twenty-server:worker:ci
server-test:
runs-on: ubuntu-latest
diff --git a/packages/twenty-server/project.json b/packages/twenty-server/project.json
index a31ff4fb101f..ed8ad716b6e0 100644
--- a/packages/twenty-server/project.json
+++ b/packages/twenty-server/project.json
@@ -77,6 +77,14 @@
"options": {
"cwd": "packages/twenty-server",
"command": "node dist/src/queue-worker/queue-worker.js"
+ },
+ "configurations": {
+ "ci": {
+ "env": {
+ "MESSAGE_QUEUE_TYPE": "sync",
+ "CACHE_STORAGE_TYPE": "memory"
+ }
+ }
}
},
"typeorm": {
From d8c4af9279ab4c16fe0e47709bc7197c33486868 Mon Sep 17 00:00:00 2001
From: Charles Bochet
Date: Sat, 5 Oct 2024 00:22:38 +0200
Subject: [PATCH 12/16] Fix all broken CIs (#7439)
Fix all the broken CIs :p
This includes an ongoing effort to simplify test maintenance by having 1
unique source of truth about metadata and data mocks (that will later be
generated from a unique source of seeds: dev = demo = test)
Regressions:
- Unit line coverage: 60 > 55
- Storybook Pages branch coverage: 40 > 35
We will need to write tests to increase those coverage
- RelationFieldDisplay perf: 0.2ms to 0.22ms > We might have a
regression here
- Removed perf story about RawJSON > We will need to re-add it
---
packages/twenty-emails/package.json | 2 +-
packages/twenty-front/.storybook/preview.tsx | 8 +-
packages/twenty-front/jest.config.ts | 3 +-
packages/twenty-front/nyc.config.cjs | 2 +-
packages/twenty-front/package.json | 2 +-
.../useRightDrawerEmailThread.test.tsx | 436 ++++++-
.../useActivityTargetObjectRecords.test.tsx | 2 +-
.../__tests__/useCreateActivityInDB.test.tsx | 54 +-
.../useOpenCreateActivityDrawer.test.tsx | 18 +-
.../tasks/__stories__/TaskGroups.stories.tsx | 3 +
.../tasks/__stories__/TaskList.stories.tsx | 11 +-
.../tasks/components/TaskGroups.tsx | 1 +
.../activities/tasks/components/TaskList.tsx | 1 +
.../hooks/__tests__/useCompleteTask.test.tsx | 131 +-
.../__tests__/useTimelineActivities.test.tsx | 9 +-
.../EventRowMainObjectUpdated.stories.tsx | 6 +-
.../ChromeExtensionSidecarProvider.tsx | 92 +-
.../favorites/hooks/__mocks__/useFavorites.ts | 820 ++++++-------
.../hooks/__tests__/useFavorites.test.tsx | 20 +-
.../__tests__/useDefaultHomePagePath.test.ts | 2 +-
.../ObjectMetadataItemsLoadEffect.tsx | 2 +-
...=> ApolloMetadataClientMockedProvider.tsx} | 0
.../hooks/__mocks__/useFieldMetadataItem.ts | 14 +-
...ColumnDefinitionsFromFieldMetadata.test.ts | 2 +-
.../useCreateOneObjectMetadataItem.test.tsx | 14 +-
.../__tests__/useFieldMetadataItem.test.tsx | 4 +-
.../useFilteredObjectMetadataItems.test.tsx | 2 +-
.../useGetObjectOrderByField.test.tsx | 12 +-
...ectRecordIdentifierByNameSingular.test.tsx | 2 +-
.../__tests__/useGetRelationMetadata.test.tsx | 2 +-
.../useMapToObjectRecordIdentifier.test.tsx | 8 +-
.../__tests__/useObjectMetadataItem.test.tsx | 14 +-
.../hooks/useObjectNamePluralFromSingular.ts | 2 +-
.../hooks/useObjectNameSingularFromPlural.ts | 2 +-
...etObjectMetadataItemBySingularName.test.ts | 2 +-
.../__tests__/getObjectOrderByField.test.ts | 2 +-
.../utils/__tests__/getObjectSlug.test.ts | 2 +-
...ObjectMetadataAvailableForRelation.test.ts | 2 +-
.../mapFieldMetadataToGraphQLQuery.test.tsx | 2 +-
.../mapObjectMetadataToGraphQLQuery.test.tsx | 2 +-
.../utils/mapObjectMetadataToGraphQLQuery.ts | 1 +
.../objectMetadataItemSchema.test.ts | 7 +-
.../__tests__/getRecordNodeFromRecord.test.ts | 40 +-
.../cache/utils/getRecordNodeFromRecord.ts | 4 +-
.../hooks/__mocks__/personFragment.ts | 48 -
.../hooks/__mocks__/personFragments.ts | 327 +++++
.../hooks/__mocks__/useCreateManyRecords.ts | 4 +-
.../hooks/__mocks__/useCreateOneRecord.ts | 4 +-
.../hooks/__mocks__/useDeleteOneRecord.ts | 2 +
.../__mocks__/useFindDuplicateRecords.ts | 4 +-
.../hooks/__mocks__/useFindOneRecord.ts | 4 +-
.../hooks/__mocks__/useUpdateOneRecord.ts | 4 +-
.../__tests__/useCreateManyRecords.test.tsx | 14 +-
.../useCreateManyRecordsMutation.test.tsx | 12 +-
.../__tests__/useCreateOneRecord.test.tsx | 14 +-
.../useCreateOneRecordMutation.test.tsx | 12 +-
.../__tests__/useDeleteManyRecords.test.tsx | 14 +-
.../useDeleteManyRecordsMutation.test.tsx | 8 +-
.../__tests__/useDeleteOneRecord.test.tsx | 17 +-
.../useDeleteOneRecordMutation.test.tsx | 10 +-
.../__tests__/useFetchAllRecordIds.test.tsx | 27 +-
.../useFindDuplicateRecords.test.tsx | 17 +-
.../useFindDuplicateRecordsQuery.test.tsx | 12 +-
.../__tests__/useFindManyRecords.test.tsx | 33 +-
.../useFindManyRecordsQuery.test.tsx | 12 +-
.../hooks/__tests__/useFindOneRecord.test.tsx | 25 +-
.../__tests__/useFindOneRecordQuery.test.tsx | 12 +-
...ordsForMultipleMetadataItemsQuery.test.tsx | 2 +-
.../__tests__/useLazyFindOneRecord.test.tsx | 17 +-
.../__tests__/useObjectRecordTable.test.tsx | 19 +-
.../__tests__/useUpdateOneRecord.test.tsx | 14 +-
.../useUpdateOneRecordMutation.test.tsx | 12 +-
.../hooks/useFindDuplicateRecords.ts | 2 +-
.../object-record/hooks/useFindManyRecords.ts | 5 +-
.../__mocks__/fieldDefinitions.ts | 27 +-
.../hooks/__tests__/usePersistField.test.tsx | 19 +-
.../__tests__/useToggleEditOnlyInput.test.tsx | 276 ++++-
.../DateTimeFieldDisplay.perf.stories.tsx | 4 +-
...sx => EmailsFieldDisplay.perf.stories.tsx} | 27 +-
.../perf/JsonFieldDisplay.perf.stories.tsx | 43 -
.../MultiSelectFieldDisplay.perf.stories.tsx | 2 +-
...sx => PhonesFieldDisplay.perf.stories.tsx} | 20 +-
.../perf/RatingFieldDisplay.perf.stories.tsx | 4 +-
...RelationToOneFieldDisplay.perf.stories.tsx | 2 +-
.../isMatchingBooleanFilter.test.ts} | 0
.../isMatchingCurrencyFilter.test.ts} | 0
.../isMatchingDateFilter.test.ts} | 0
.../isMatchingFloatFilter.test.ts} | 0
.../isMatchingStringFilter.test.ts} | 0
.../isMatchingUUIDFilter.test.ts} | 0
.../isRecordMatchingFilter.test.ts} | 5 +-
...bjectDropdownFilterIntoQueryFilter.test.ts | 1063 +++++++++++++++++
...turnObjectDropdownFilterIntoQueryFilter.ts | 52 -
.../hooks/useLoadRecordIndexTable.ts | 4 +
.../hooks/__tests__/useTableData.test.tsx | 120 +-
.../RecordDetailRelationSection.stories.tsx | 10 +-
.../perf/RecordTableCell.perf.stories.tsx | 2 +-
.../hooks/__tests__/useUpsertRecord.test.tsx | 2 +-
...attedAsObjectRecordForSelectArray.test.tsx | 2 +-
...jectRecordsSpreasheetImportDialog.test.tsx | 274 ++++-
.../useFilteredSearchEntityQuery.test.tsx | 2 +-
...AccountsCalendarChannelDetails.stories.tsx | 8 +-
...ccountsCalendarChannelsGeneral.stories.tsx | 8 +-
...sAccountsMessageChannelDetails.stories.tsx | 8 +-
...sDataModelFieldDescriptionForm.stories.tsx | 10 +-
...ngsDataModelFieldIconLabelForm.stories.tsx | 10 +-
...DataModelFieldSettingsFormCard.stories.tsx | 10 +-
...tingsDataModelFieldPreviewCard.stories.tsx | 37 +-
.../__tests__/useFieldPreviewValue.test.tsx | 43 +-
.../getCurrencyFieldPreviewValue.test.ts | 18 +-
.../__tests__/getFieldPreviewValue.test.ts | 38 +-
.../getMultiSelectFieldPreviewValue.test.ts | 50 +-
.../getSelectFieldPreviewValue.test.ts | 30 +-
...ttingsDataModelObjectAboutForm.stories.tsx | 5 +-
.../getFieldPreviewValueFromRecord.test.ts | 36 +-
...eServerlessFunctionUpdateFormState.test.ts | 4 +-
.../useServerlessFunctionUpdateFormState.ts | 2 +-
.../__stories__/RecordShowPage.stories.tsx | 8 -
.../SettingsAppearance.stories.tsx | 36 +-
.../SettingsIntegrationDatabase.stories.tsx | 3 +-
.../decorators/ChipGeneratorsDecorator.tsx | 2 +-
.../src/testing/decorators/PageDecorator.tsx | 53 +-
.../src/testing/decorators/RootDecorator.tsx | 2 +-
.../testing/decorators/getFieldDecorator.tsx | 4 +-
.../twenty-front/src/testing/graphqlMocks.ts | 26 +-
.../jest/JestObjectMetadataItemSetter.tsx | 2 +-
.../jest/generateEmptyJestRecordNode.ts | 37 +
... getJestMetadataAndApolloMocksWrapper.tsx} | 2 +-
.../generated/mock-metadata-query-result.ts | 539 ++++++++-
...ts => generatedMockObjectMetadataItems.ts} | 0
.../src/testing/mock-data/metadata.ts | 290 -----
.../src/testing/mock-data/users.ts | 2 +
.../src/testing/mock-data/view-fields.ts | 218 +++-
.../src/testing/mock-data/views.ts | 823 +------------
.../src/utils/format/__tests__/number.test.ts | 7 +-
packages/twenty-server/jest.config.ts | 2 +-
packages/twenty-server/package.json | 2 +-
.../src/constants/assets-path.ts | 7 +-
.../typeorm-seeds/metadata/fieldsMetadata.ts | 2 +-
...ld-metadata-to-graphql-query.utils.spec.ts | 3 +
.../utils/__tests__/components.utils.spec.ts | 9 +-
.../get-base-typescript-project-files.ts | 2 +-
.../test/people.integration-spec.ts | 4 +-
.../serverless-functions.integration-spec.ts | 61 -
.../twenty-server/test/utils/setup-test.ts | 2 +-
packages/twenty-ui/package.json | 2 +-
.../display/icon/components/TablerIcons.ts | 2 +-
packages/twenty-website/package.json | 2 +-
148 files changed, 4363 insertions(+), 2542 deletions(-)
rename packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/{ApolloMetadataClientProvider.tsx => ApolloMetadataClientMockedProvider.tsx} (100%)
delete mode 100644 packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragment.ts
create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts
rename packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/{EmailFieldDisplay.perf.stories.tsx => EmailsFieldDisplay.perf.stories.tsx} (55%)
delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/JsonFieldDisplay.perf.stories.tsx
rename packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/{PhoneFieldDisplay.perf.stories.tsx => PhonesFieldDisplay.perf.stories.tsx} (61%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingBooleanFilter.spec.ts => __tests__/isMatchingBooleanFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingCurrencyFilter.spec.ts => __tests__/isMatchingCurrencyFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingDateFilter.spec.ts => __tests__/isMatchingDateFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingFloatFilter.spec.ts => __tests__/isMatchingFloatFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingStringFilter.spec.ts => __tests__/isMatchingStringFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isMatchingUUIDFilter.spec.ts => __tests__/isMatchingUUIDFilter.test.ts} (100%)
rename packages/twenty-front/src/modules/object-record/record-filter/utils/{isRecordMatchingFilter.spec.ts => __tests__/isRecordMatchingFilter.test.ts} (98%)
create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/turnObjectDropdownFilterIntoQueryFilter.test.ts
create mode 100644 packages/twenty-front/src/testing/jest/generateEmptyJestRecordNode.ts
rename packages/twenty-front/src/testing/jest/{getJestHookWrapper.tsx => getJestMetadataAndApolloMocksWrapper.tsx} (94%)
rename packages/twenty-front/src/testing/mock-data/{objectMetadataItems.ts => generatedMockObjectMetadataItems.ts} (100%)
delete mode 100644 packages/twenty-front/src/testing/mock-data/metadata.ts
delete mode 100644 packages/twenty-server/test/serverless-functions.integration-spec.ts
diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json
index a792a42a373a..1f20fbe7eb64 100644
--- a/packages/twenty-emails/package.json
+++ b/packages/twenty-emails/package.json
@@ -1,6 +1,6 @@
{
"name": "twenty-emails",
- "version": "0.31.canary",
+ "version": "0.31.0-canary",
"description": "",
"author": "",
"private": true,
diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx
index f49ed56c58b9..1d67634e2a54 100644
--- a/packages/twenty-front/.storybook/preview.tsx
+++ b/packages/twenty-front/.storybook/preview.tsx
@@ -1,7 +1,7 @@
-import { useEffect } from 'react';
import { ThemeProvider } from '@emotion/react';
import { Preview } from '@storybook/react';
import { initialize, mswDecorator } from 'msw-storybook-addon';
+import { useEffect } from 'react';
import { useDarkMode } from 'storybook-dark-mode';
import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui';
@@ -13,12 +13,16 @@ import 'react-loading-skeleton/dist/skeleton.css';
initialize({
onUnhandledRequest: async (request: Request) => {
const fileExtensionsToIgnore =
- /\.(ts|tsx|js|jsx|svg|css|png)(\?v=[a-zA-Z0-9]+)?/;
+ /\.(ts|tsx|js|jsx|svg|css|png|woff2)(\?v=[a-zA-Z0-9]+)?/;
if (fileExtensionsToIgnore.test(request.url)) {
return;
}
+ if (request.url.startsWith('http://localhost:3000/files/data:image')) {
+ return;
+ }
+
const requestBody = await request.json();
// eslint-disable-next-line no-console
console.warn(`Unhandled ${request.method} request to ${request.url}
diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts
index c71df7b77aff..8ed7f398db4e 100644
--- a/packages/twenty-front/jest.config.ts
+++ b/packages/twenty-front/jest.config.ts
@@ -2,6 +2,7 @@ import { JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const tsConfig = require('./tsconfig.json');
+process.env.TZ = 'GMT';
const jestConfig: JestConfigWithTsJest = {
// to enable logs, comment out the following line
@@ -25,7 +26,7 @@ const jestConfig: JestConfigWithTsJest = {
coverageThreshold: {
global: {
statements: 60,
- lines: 60,
+ lines: 55,
functions: 50,
},
},
diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs
index 3fbf2dfd6204..8ae501c6910f 100644
--- a/packages/twenty-front/nyc.config.cjs
+++ b/packages/twenty-front/nyc.config.cjs
@@ -16,7 +16,7 @@ const modulesCoverage = {
};
const pagesCoverage = {
- branches: 40,
+ branches: 35,
statements: 60,
lines: 60,
functions: 45,
diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index 96e6442a3f56..9370f2460b92 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -1,6 +1,6 @@
{
"name": "twenty-front",
- "version": "0.31.canary",
+ "version": "0.31.0-canary",
"private": true,
"type": "module",
"scripts": {
diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx
index f88c7b79a1b5..0af5cec8224a 100644
--- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx
+++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx
@@ -1,56 +1,410 @@
-import { MockedProvider } from '@apollo/client/testing';
-import { renderHook } from '@testing-library/react';
-import { RecoilRoot } from 'recoil';
-
-import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
-import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
+import { renderHook, waitFor } from '@testing-library/react';
+import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
+import gql from 'graphql-tag';
+import { generateEmptyJestRecordNode } from '~/testing/jest/generateEmptyJestRecordNode';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useRightDrawerEmailThread } from '../useRightDrawerEmailThread';
-jest.mock('@/object-record/hooks/useFindOneRecord', () => ({
- __esModule: true,
- useFindOneRecord: jest.fn(),
-}));
+const mocks = [
+ {
+ request: {
+ query: gql`
+ query FindOneMessageThread($objectRecordId: ID!) {
+ messageThread(filter: { id: { eq: $objectRecordId } }) {
+ __typename
+ id
+ }
+ }
+ `,
+ variables: { objectRecordId: '1' },
+ },
+ result: jest.fn(() => ({
+ data: {
+ messageThread: {
+ id: '1',
+ __typename: 'MessageThread',
+ },
+ },
+ })),
+ },
+ {
+ request: {
+ query: gql`
+ query FindManyMessages(
+ $filter: MessageFilterInput
+ $orderBy: [MessageOrderByInput]
+ $lastCursor: String
+ $limit: Int
+ ) {
+ messages(
+ filter: $filter
+ orderBy: $orderBy
+ first: $limit
+ after: $lastCursor
+ ) {
+ edges {
+ node {
+ __typename
+ createdAt
+ headerMessageId
+ id
+ messageParticipants {
+ edges {
+ node {
+ __typename
+ displayName
+ handle
+ id
+ person {
+ __typename
+ avatarUrl
+ city
+ companyId
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ emails {
+ primaryEmail
+ additionalEmails
+ }
+ id
+ intro
+ jobTitle
+ linkedinLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ name {
+ firstName
+ lastName
+ }
+ performanceRating
+ phones {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ position
+ updatedAt
+ whatsapp {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ workPreference
+ xLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ }
+ role
+ workspaceMember {
+ __typename
+ avatarUrl
+ colorScheme
+ createdAt
+ dateFormat
+ deletedAt
+ id
+ locale
+ name {
+ firstName
+ lastName
+ }
+ timeFormat
+ timeZone
+ updatedAt
+ userEmail
+ userId
+ }
+ }
+ }
+ }
+ messageThread {
+ __typename
+ id
+ }
+ receivedAt
+ subject
+ text
+ }
+ cursor
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ totalCount
+ }
+ }
+ `,
+ variables: {
+ filter: { messageThreadId: { eq: '1' } },
+ orderBy: [{ receivedAt: 'AscNullsLast' }],
+ lastCursor: undefined,
+ limit: 10,
+ },
+ },
+ result: jest.fn(() => ({
+ data: {
+ messages: {
+ edges: [
+ {
+ node: generateEmptyJestRecordNode({
+ objectNameSingular: 'message',
+ input: {
+ id: '1',
+ text: 'Message 1',
+ createdAt: '2024-10-03T10:20:10.145Z',
+ },
+ }),
+ cursor: '1',
+ },
+ {
+ node: generateEmptyJestRecordNode({
+ objectNameSingular: 'message',
+ input: {
+ id: '2',
+ text: 'Message 2',
+ createdAt: '2024-10-03T10:20:10.145Z',
+ },
+ }),
+ cursor: '2',
+ },
+ ],
+ totalCount: 2,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '1',
+ endCursor: '2',
+ },
+ },
+ },
+ })),
+ },
+ {
+ request: {
+ query: gql`
+ query FindManyMessageParticipants(
+ $filter: MessageParticipantFilterInput
+ $orderBy: [MessageParticipantOrderByInput]
+ $lastCursor: String
+ $limit: Int
+ ) {
+ messageParticipants(
+ filter: $filter
+ orderBy: $orderBy
+ first: $limit
+ after: $lastCursor
+ ) {
+ edges {
+ node {
+ __typename
+ displayName
+ handle
+ id
+ messageId
+ person {
+ __typename
+ avatarUrl
+ city
+ companyId
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ emails {
+ primaryEmail
+ additionalEmails
+ }
+ id
+ intro
+ jobTitle
+ linkedinLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ name {
+ firstName
+ lastName
+ }
+ performanceRating
+ phones {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ position
+ updatedAt
+ whatsapp {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ workPreference
+ xLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ }
+ role
+ workspaceMember {
+ __typename
+ avatarUrl
+ colorScheme
+ createdAt
+ dateFormat
+ deletedAt
+ id
+ locale
+ name {
+ firstName
+ lastName
+ }
+ timeFormat
+ timeZone
+ updatedAt
+ userEmail
+ userId
+ }
+ }
+ cursor
+ }
+ pageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+ }
+ totalCount
+ }
+ }
+ `,
+ variables: {
+ filter: { messageId: { in: ['1', '2'] }, role: { eq: 'from' } },
+ orderBy: undefined,
+ lastCursor: undefined,
+ limit: undefined,
+ },
+ },
+ result: jest.fn(() => ({
+ data: {
+ messageParticipants: {
+ edges: [
+ {
+ node: generateEmptyJestRecordNode({
+ objectNameSingular: 'messageParticipant',
+ input: {
+ id: 'messageParticipant-1',
+ role: 'from',
+ messageId: '1',
+ },
+ }),
+ cursor: '1',
+ },
+ {
+ node: generateEmptyJestRecordNode({
+ objectNameSingular: 'messageParticipant',
+ input: {
+ id: 'messageParticipant-2',
+ role: 'from',
+ messageId: '2',
+ },
+ }),
+ cursor: '2',
+ },
+ ],
+ totalCount: 2,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '1',
+ endCursor: '2',
+ },
+ },
+ },
+ })),
+ },
+];
-jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
- __esModule: true,
- useFindManyRecords: jest.fn(),
-}));
+const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: mocks,
+ onInitializeRecoilSnapshot: ({ set }) => {
+ set(viewableRecordIdState, '1');
+ },
+});
describe('useRightDrawerEmailThread', () => {
it('should return correct values', async () => {
- const mockThread = { id: '1' };
-
const mockMessages = [
- { id: '1', text: 'Message 1' },
- { id: '2', text: 'Message 2' },
+ {
+ __typename: 'Message',
+ createdAt: '2024-10-03T10:20:10.145Z',
+ headerMessageId: '',
+ id: '1',
+ messageParticipants: [],
+ messageThread: null,
+ receivedAt: null,
+ sender: {
+ __typename: 'MessageParticipant',
+ displayName: '',
+ handle: '',
+ id: 'messageParticipant-1',
+ messageId: '1',
+ person: null,
+ role: 'from',
+ workspaceMember: null,
+ },
+ subject: '',
+ text: 'Message 1',
+ },
+ {
+ __typename: 'Message',
+ createdAt: '2024-10-03T10:20:10.145Z',
+ headerMessageId: '',
+ id: '2',
+ messageParticipants: [],
+ messageThread: null,
+ receivedAt: null,
+ sender: {
+ __typename: 'MessageParticipant',
+ displayName: '',
+ handle: '',
+ id: 'messageParticipant-2',
+ messageId: '2',
+ person: null,
+ role: 'from',
+ workspaceMember: null,
+ },
+ subject: '',
+ text: 'Message 2',
+ },
];
- const mockFetchMoreRecords = jest.fn();
-
- (useFindOneRecord as jest.Mock).mockReturnValue({
- record: mockThread,
- loading: false,
- fetchMoreRecords: mockFetchMoreRecords,
- });
-
- (useFindManyRecords as jest.Mock).mockReturnValue({
- records: mockMessages,
- loading: false,
- fetchMoreRecords: mockFetchMoreRecords,
- });
-
const { result } = renderHook(() => useRightDrawerEmailThread(), {
- wrapper: ({ children }) => (
-
- {children}
-
- ),
+ wrapper: Wrapper,
});
- expect(result.current.thread).toBeDefined();
- expect(result.current.messages).toEqual(mockMessages);
- expect(result.current.threadLoading).toBeFalsy();
- expect(result.current.fetchMoreMessages).toBeInstanceOf(Function);
+ await waitFor(() => {
+ expect(result.current.thread).toBeDefined();
+ expect(result.current.messages).toEqual(mockMessages);
+ expect(result.current.threadLoading).toBeFalsy();
+ expect(result.current.fetchMoreMessages).toBeInstanceOf(Function);
+ });
});
});
diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx
index 260e931a3ae2..7d1426f4cb75 100644
--- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx
+++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx
@@ -9,7 +9,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
-import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
+import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members';
const cache = new InMemoryCache();
diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx
index 1b56bc49d5da..baddb1029bda 100644
--- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx
+++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx
@@ -1,13 +1,11 @@
-import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import { MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import gql from 'graphql-tag';
import pick from 'lodash.pick';
-import { ReactNode } from 'react';
-import { RecoilRoot } from 'recoil';
import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
-import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { mockedTasks } from '~/testing/mock-data/tasks';
const mockedDate = '2024-03-15T12:00:00.000Z';
@@ -26,14 +24,44 @@ const mocks: MockedResponse[] = [
mutation CreateOneTask($input: TaskCreateInput!) {
createTask(data: $input) {
__typename
- updatedAt
+ assignee {
+ __typename
+ id
+ name {
+ firstName
+ lastName
+ }
+ }
+ assigneeId
+ attachments {
+ edges {
+ node {
+ __typename
+ activityId
+ authorId
+ companyId
+ createdAt
+ deletedAt
+ fullPath
+ id
+ name
+ noteId
+ opportunityId
+ personId
+ rocketId
+ taskId
+ type
+ updatedAt
+ }
+ }
+ }
+ body
createdAt
dueAt
id
status
- body
- assigneeId
title
+ updatedAt
}
}
`,
@@ -56,15 +84,9 @@ const mocks: MockedResponse[] = [
},
];
-const Wrapper = ({ children }: { children: ReactNode }) => (
-
-
-
- {children}
-
-
-
-);
+const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: mocks,
+});
describe('useCreateActivityInDB', () => {
it('Should create activity in DB', async () => {
diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx
index 665702704d1b..855c0b55bd29 100644
--- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx
+++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx
@@ -1,7 +1,6 @@
-import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import { MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
-import { ReactNode } from 'react';
-import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil';
+import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
@@ -9,7 +8,8 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import gql from 'graphql-tag';
import pick from 'lodash.pick';
-import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
+import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { mockedTasks } from '~/testing/mock-data/tasks';
const mockedDate = '2024-03-15T12:00:00.000Z';
@@ -61,13 +61,9 @@ const mocks: MockedResponse[] = [
},
];
-const Wrapper = ({ children }: { children: ReactNode }) => (
-
-
- {children}
-
-
-);
+const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: mocks,
+});
const mockObjectMetadataItems = generatedMockObjectMetadataItems;
diff --git a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx
index 444049cc1844..aefa6f2ed59a 100644
--- a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx
+++ b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx
@@ -42,5 +42,8 @@ export const WithTasks: Story = {
},
parameters: {
msw: graphqlMocks,
+ container: {
+ width: '500px',
+ },
},
};
diff --git a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx
index 1113febb6223..c2a65b772be9 100644
--- a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx
+++ b/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx
@@ -3,6 +3,7 @@ import { ComponentDecorator } from 'twenty-ui';
import { TaskList } from '@/activities/tasks/components/TaskList';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
+import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { mockedTasks } from '~/testing/mock-data/tasks';
@@ -10,13 +11,21 @@ import { mockedTasks } from '~/testing/mock-data/tasks';
const meta: Meta = {
title: 'Modules/Activity/TaskList',
component: TaskList,
- decorators: [MemoryRouterDecorator, ComponentDecorator, SnackBarDecorator],
+ decorators: [
+ ComponentDecorator,
+ MemoryRouterDecorator,
+ ObjectMetadataItemsDecorator,
+ SnackBarDecorator,
+ ],
args: {
title: 'Tasks',
tasks: mockedTasks,
},
parameters: {
msw: graphqlMocks,
+ container: {
+ width: '500px',
+ },
},
};
diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx
index ae484382ffcc..16ebbec0f38a 100644
--- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx
+++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx
@@ -27,6 +27,7 @@ import { TaskList } from './TaskList';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
+ width: 100%;
`;
type TaskGroupsProps = {
diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx
index 04e56c8d65ed..10e7982ca04f 100644
--- a/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx
+++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskList.tsx
@@ -12,6 +12,7 @@ type TaskListProps = {
const StyledContainer = styled.div`
align-items: flex-start;
+ width: 100%;
align-self: stretch;
display: flex;
flex-direction: column;
diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx
index ba3e2ac53482..814b72fb6ce1 100644
--- a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx
+++ b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx
@@ -1,11 +1,10 @@
-import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import { MockedResponse } from '@apollo/client/testing';
import { act, renderHook } from '@testing-library/react';
import gql from 'graphql-tag';
-import { ReactNode } from 'react';
-import { RecoilRoot } from 'recoil';
import { useCompleteTask } from '@/activities/tasks/hooks/useCompleteTask';
import { Task } from '@/activities/types/Task';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const task: Task = {
id: '123',
@@ -28,21 +27,123 @@ const mocks: MockedResponse[] = [
mutation UpdateOneTask($idToUpdate: ID!, $input: TaskUpdateInput!) {
updateTask(id: $idToUpdate, data: $input) {
__typename
- updatedAt
- createdAt
- deletedAt
- dueAt
- id
- status
+ assignee {
+ __typename
+ avatarUrl
+ colorScheme
+ createdAt
+ dateFormat
+ deletedAt
+ id
+ locale
+ name {
+ firstName
+ lastName
+ }
+ timeFormat
+ timeZone
+ updatedAt
+ userEmail
+ userId
+ }
+ assigneeId
+ attachments {
+ edges {
+ node {
+ __typename
+ activityId
+ authorId
+ companyId
+ createdAt
+ deletedAt
+ fullPath
+ id
+ name
+ noteId
+ opportunityId
+ personId
+ rocketId
+ taskId
+ type
+ updatedAt
+ }
+ }
+ }
body
+ createdAt
createdBy {
source
workspaceMemberId
name
}
- assigneeId
+ deletedAt
+ dueAt
+ favorites {
+ edges {
+ node {
+ __typename
+ companyId
+ createdAt
+ deletedAt
+ id
+ noteId
+ opportunityId
+ personId
+ position
+ rocketId
+ taskId
+ updatedAt
+ viewId
+ workflowId
+ workspaceMemberId
+ }
+ }
+ }
+ id
position
+ status
+ taskTargets {
+ edges {
+ node {
+ __typename
+ companyId
+ createdAt
+ deletedAt
+ id
+ opportunityId
+ personId
+ rocketId
+ taskId
+ updatedAt
+ }
+ }
+ }
+ timelineActivities {
+ edges {
+ node {
+ __typename
+ companyId
+ createdAt
+ deletedAt
+ happensAt
+ id
+ linkedObjectMetadataId
+ linkedRecordCachedName
+ linkedRecordId
+ name
+ noteId
+ opportunityId
+ personId
+ properties
+ rocketId
+ taskId
+ updatedAt
+ workspaceMemberId
+ }
+ }
+ }
title
+ updatedAt
}
}
`,
@@ -72,13 +173,9 @@ const mocks: MockedResponse[] = [
},
];
-const Wrapper = ({ children }: { children: ReactNode }) => (
-
-
- {children}
-
-
-);
+const Wrapper = getJestMetadataAndApolloMocksWrapper({
+ apolloMocks: mocks,
+});
describe('useCompleteTask', () => {
it('should complete task', async () => {
diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx
index 894d541c888b..2d1989cc68ea 100644
--- a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx
+++ b/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx
@@ -1,21 +1,16 @@
import { renderHook } from '@testing-library/react';
import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities';
-import { ReactNode } from 'react';
-import { getJestHookWrapper } from '~/testing/jest/getJestHookWrapper';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: jest.fn(),
}));
-const Wrappers = getJestHookWrapper({
+const Wrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
-const Wrapper = ({ children }: { children: ReactNode }) => (
- {children}
-);
-
describe('useTimelineActivities', () => {
afterEach(() => {
jest.clearAllMocks();
diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx
index 0cb945bdc4b2..224438b853cf 100644
--- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx
+++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx
@@ -5,7 +5,7 @@ import { EventRowMainObjectUpdated } from '@/activities/timelineActivities/rows/
import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
-import { mockedPersonObjectMetadataItem } from '~/testing/mock-data/metadata';
+import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
const meta: Meta = {
title: 'Modules/TimelineActivities/Rows/MainObject/EventRowMainObjectUpdated',
@@ -35,7 +35,9 @@ const meta: Meta = {
},
},
} as TimelineActivity,
- mainObjectMetadataItem: mockedPersonObjectMetadataItem,
+ mainObjectMetadataItem: generatedMockObjectMetadataItems.find(
+ (item) => item.namePlural === 'person',
+ ),
},
decorators: [
ComponentDecorator,
diff --git a/packages/twenty-front/src/modules/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider.tsx b/packages/twenty-front/src/modules/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider.tsx
index 3c4d8556a4bf..9409540ead23 100644
--- a/packages/twenty-front/src/modules/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider.tsx
+++ b/packages/twenty-front/src/modules/chrome-extension-sidecar/components/ChromeExtensionSidecarProvider.tsx
@@ -1,56 +1,46 @@
-import styled from '@emotion/styled';
-import { useRecoilValue } from 'recoil';
-
-import { isLoadingTokensFromExtensionState } from '@/chrome-extension-sidecar/states/isLoadingTokensFromExtensionState';
-import { chromeExtensionIdState } from '@/client-config/states/chromeExtensionIdState';
-import { isDefined } from '~/utils/isDefined';
-import { isInFrame } from '~/utils/isInIframe';
-
-const StyledContainer = styled.div`
- align-items: center;
- display: flex;
- flex-direction: column;
- height: 100vh;
- justify-content: center;
-`;
-
-const AppInaccessible = ({ message }: { message: string }) => {
- return (
-
-
- {message}
-
- );
-};
+// const StyledContainer = styled.div`
+// align-items: center;
+// display: flex;
+// flex-direction: column;
+// height: 100vh;
+// justify-content: center;
+// `;
+
+// const AppInaccessible = ({ message }: { message: string }) => {
+// return (
+//
+//
+// {message}
+//
+// );
+// };
export const ChromeExtensionSidecarProvider: React.FC<
React.PropsWithChildren
> = ({ children }) => {
- const isLoadingTokensFromExtension = useRecoilValue(
- isLoadingTokensFromExtensionState,
- );
- const chromeExtensionId = useRecoilValue(chromeExtensionIdState);
-
- if (!isInFrame()) return <>{children}>;
-
- if (!isDefined(chromeExtensionId))
- return (
-
- );
-
- if (isDefined(isLoadingTokensFromExtension) && !isLoadingTokensFromExtension)
- return (
-
- );
-
- return isLoadingTokensFromExtension && <>{children}>;
+ return <>{children}>;
+
+ // TODO: this is conflictting with storybook tests
+ // if (!isInFrame()) return <>{children}>;
+
+ // if (!isDefined(chromeExtensionId))
+ // return (
+ //
+ // );
+
+ // if (isDefined(isLoadingTokensFromExtension) && !isLoadingTokensFromExtension)
+ // return (
+ //
+ // );
+
+ // return isLoadingTokensFromExtension && <>{children}>;
};
diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts
index 98813ad384c7..cf0fe8e888b1 100644
--- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts
+++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts
@@ -87,262 +87,264 @@ export const mocks = [
mutation CreateOneFavorite($input: FavoriteCreateInput!) {
createFavorite(data: $input) {
__typename
- noteId
- taskId
- person {
+ company {
__typename
- name {
- firstName
- lastName
+ accountOwnerId
+ address {
+ addressStreet1
+ addressStreet2
+ addressCity
+ addressState
+ addressCountry
+ addressPostcode
+ addressLat
+ addressLng
+ }
+ annualRecurringRevenue {
+ amountMicros
+ currencyCode
+ }
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ domainName {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ employees
+ id
+ idealCustomerProfile
+ introVideo {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
- deletedAt
- createdAt
+ name
+ position
+ tagline
updatedAt
- jobTitle
- intro
- workPrefereance
- performanceRating
+ visaSponsorship
+ workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
- city
- companyId
- phones {
- primaryPhoneNumber
- primaryPhoneCountryCode
- additionalPhones
+ }
+ companyId
+ createdAt
+ deletedAt
+ id
+ note {
+ __typename
+ body
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ id
+ position
+ title
+ updatedAt
+ }
+ noteId
+ opportunity {
+ __typename
+ amount {
+ amountMicros
+ currencyCode
}
+ closeDate
+ companyId
+ createdAt
createdBy {
source
workspaceMemberId
name
}
+ deletedAt
id
+ name
+ pointOfContactId
position
+ stage
+ updatedAt
+ }
+ opportunityId
+ person {
+ __typename
+ avatarUrl
+ city
+ companyId
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
emails {
primaryEmail
additionalEmails
}
- avatarUrl
+ id
+ intro
+ jobTitle
+ linkedinLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ name {
+ firstName
+ lastName
+ }
+ performanceRating
+ phones {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ position
+ updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
+ workPreference
+ xLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
}
- task {
+ personId
+ position
+ rocket {
__typename
- updatedAt
createdAt
- deletedAt
- dueAt
- id
- status
- body
createdBy {
source
workspaceMemberId
name
}
- assigneeId
- position
- title
- }
- rocketId
- viewId
- updatedAt
- workflowId
- personId
- workspaceMemberId
- note {
- __typename
deletedAt
id
+ name
position
updatedAt
+ }
+ rocketId
+ task {
+ __typename
+ assigneeId
+ body
+ createdAt
createdBy {
source
workspaceMemberId
name
}
- body
+ deletedAt
+ dueAt
+ id
+ position
+ status
title
- createdAt
+ updatedAt
}
- createdAt
+ taskId
+ updatedAt
view {
__typename
- id
- type
+ createdAt
+ deletedAt
icon
- key
+ id
isCompact
kanbanFieldMetadataId
+ key
+ name
objectMetadataId
position
- createdAt
- deletedAt
+ type
updatedAt
- name
}
- opportunityId
- position
- deletedAt
- id
- companyId
+ viewId
workflow {
__typename
- deletedAt
- lastPublishedVersionId
createdAt
+ deletedAt
id
- statuses
+ lastPublishedVersionId
name
position
+ statuses
updatedAt
}
+ workflowId
workspaceMember {
__typename
+ avatarUrl
+ colorScheme
+ createdAt
+ dateFormat
+ deletedAt
+ id
+ locale
name {
firstName
lastName
}
- avatarUrl
- userId
- createdAt
- timeZone
- id
timeFormat
+ timeZone
updatedAt
- locale
userEmail
- deletedAt
- colorScheme
- dateFormat
+ userId
}
- company {
- __typename
- updatedAt
- domainName {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- visaSponsorship
- address {
- addressStreet1
- addressStreet2
- addressCity
- addressState
- addressCountry
- addressPostcode
- addressLat
- addressLng
- }
- position
- employees
- deletedAt
- accountOwnerId
- annualRecurringRevenue {
- amountMicros
- currencyCode
- }
- id
- name
- xLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- createdAt
- createdBy {
- source
- workspaceMemberId
- name
- }
- workPolicy
- introVideo {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- linkedinLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- tagline
- idealCustomerProfile
- }
- rocket {
- __typename
- createdBy {
- source
- workspaceMemberId
- name
- }
- updatedAt
- name
- position
- createdAt
- id
- deletedAt
- }
- opportunity {
- __typename
- createdBy {
- source
- workspaceMemberId
- name
- }
- amount {
- amountMicros
- currencyCode
- }
- stage
- position
- closeDate
- id
- name
- pointOfContactId
- companyId
- updatedAt
- deletedAt
- createdAt
- }
- }
- }
- `,
- variables: {
- input: {
- id: mockId,
- personId: favoriteTargetObjectId,
- position: 4,
- workspaceMemberId: '1',
- },
- },
- },
- result: jest.fn(() => ({
- data: {
- createFavorite: {
- id: favoriteId,
- },
- },
- })),
- },
- {
- request: {
- query: gql`
- mutation DeleteOneFavorite($idToDelete: ID!) {
- deleteFavorite(id: $idToDelete) {
- id
+ workspaceMemberId
+ }
+ }
+ `,
+ variables: {
+ input: {
+ id: mockId,
+ personId: favoriteTargetObjectId,
+ position: 4,
+ workspaceMemberId: '1',
+ },
+ },
+ },
+ result: jest.fn(() => ({
+ data: {
+ createFavorite: {
+ id: favoriteId,
+ },
+ },
+ })),
+ },
+ {
+ request: {
+ query: gql`
+ mutation DeleteOneFavorite($idToDelete: ID!) {
+ deleteFavorite(id: $idToDelete) {
+ __typename
+ deletedAt
+ id
}
}
`,
@@ -365,236 +367,236 @@ export const mocks = [
) {
updateFavorite(id: $idToUpdate, data: $input) {
__typename
- noteId
- taskId
- person {
- __typename
- name {
- firstName
- lastName
- }
- linkedinLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- deletedAt
- createdAt
- updatedAt
- jobTitle
- intro
- workPrefereance
- performanceRating
- xLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- city
- companyId
- phones {
- primaryPhoneNumber
- primaryPhoneCountryCode
- additionalPhones
- }
- createdBy {
- source
- workspaceMemberId
- name
- }
- id
- position
- emails {
- primaryEmail
- additionalEmails
- }
- avatarUrl
- whatsapp {
- primaryPhoneNumber
- primaryPhoneCountryCode
- additionalPhones
- }
- }
- task {
- __typename
- updatedAt
- createdAt
- deletedAt
- dueAt
- id
- status
- body
- createdBy {
- source
- workspaceMemberId
- name
- }
- assigneeId
- position
- title
- }
- rocketId
- viewId
- updatedAt
- workflowId
- personId
- workspaceMemberId
- note {
- __typename
- deletedAt
- id
- position
- updatedAt
- createdBy {
- source
- workspaceMemberId
- name
- }
- body
- title
- createdAt
- }
- createdAt
- view {
- __typename
- id
- type
- icon
- key
- isCompact
- kanbanFieldMetadataId
- objectMetadataId
- position
- createdAt
- deletedAt
- updatedAt
- name
- }
- opportunityId
- position
- deletedAt
- id
- companyId
- workflow {
- __typename
- deletedAt
- lastPublishedVersionId
- createdAt
- id
- statuses
- name
- position
- updatedAt
- }
- workspaceMember {
- __typename
- name {
- firstName
- lastName
- }
- avatarUrl
- userId
- createdAt
- timeZone
- id
- timeFormat
- updatedAt
- locale
- userEmail
- deletedAt
- colorScheme
- dateFormat
- }
- company {
- __typename
- updatedAt
- domainName {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- visaSponsorship
- address {
- addressStreet1
- addressStreet2
- addressCity
- addressState
- addressCountry
- addressPostcode
- addressLat
- addressLng
- }
- position
- employees
- deletedAt
- accountOwnerId
- annualRecurringRevenue {
- amountMicros
- currencyCode
- }
- id
- name
- xLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- createdAt
- createdBy {
- source
- workspaceMemberId
- name
- }
- workPolicy
- introVideo {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- linkedinLink {
- primaryLinkUrl
- primaryLinkLabel
- secondaryLinks
- }
- tagline
- idealCustomerProfile
- }
- rocket {
- __typename
- createdBy {
- source
- workspaceMemberId
- name
- }
- updatedAt
- name
- position
- createdAt
- id
- deletedAt
- }
- opportunity {
- __typename
- createdBy {
- source
+ company {
+ __typename
+ accountOwnerId
+ address {
+ addressStreet1
+ addressStreet2
+ addressCity
+ addressState
+ addressCountry
+ addressPostcode
+ addressLat
+ addressLng
+ }
+ annualRecurringRevenue {
+ amountMicros
+ currencyCode
+ }
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ domainName {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ employees
+ id
+ idealCustomerProfile
+ introVideo {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ linkedinLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ name
+ position
+ tagline
+ updatedAt
+ visaSponsorship
+ workPolicy
+ xLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ }
+ companyId
+ createdAt
+ deletedAt
+ id
+ note {
+ __typename
+ body
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ id
+ position
+ title
+ updatedAt
+ }
+ noteId
+ opportunity {
+ __typename
+ amount {
+ amountMicros
+ currencyCode
+ }
+ closeDate
+ companyId
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ id
+ name
+ pointOfContactId
+ position
+ stage
+ updatedAt
+ }
+ opportunityId
+ person {
+ __typename
+ avatarUrl
+ city
+ companyId
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ emails {
+ primaryEmail
+ additionalEmails
+ }
+ id
+ intro
+ jobTitle
+ linkedinLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ name {
+ firstName
+ lastName
+ }
+ performanceRating
+ phones {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ position
+ updatedAt
+ whatsapp {
+ primaryPhoneNumber
+ primaryPhoneCountryCode
+ additionalPhones
+ }
+ workPreference
+ xLink {
+ primaryLinkUrl
+ primaryLinkLabel
+ secondaryLinks
+ }
+ }
+ personId
+ position
+ rocket {
+ __typename
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ id
+ name
+ position
+ updatedAt
+ }
+ rocketId
+ task {
+ __typename
+ assigneeId
+ body
+ createdAt
+ createdBy {
+ source
+ workspaceMemberId
+ name
+ }
+ deletedAt
+ dueAt
+ id
+ position
+ status
+ title
+ updatedAt
+ }
+ taskId
+ updatedAt
+ view {
+ __typename
+ createdAt
+ deletedAt
+ icon
+ id
+ isCompact
+ kanbanFieldMetadataId
+ key
+ name
+ objectMetadataId
+ position
+ type
+ updatedAt
+ }
+ viewId
+ workflow {
+ __typename
+ createdAt
+ deletedAt
+ id
+ lastPublishedVersionId
+ name
+ position
+ statuses
+ updatedAt
+ }
+ workflowId
+ workspaceMember {
+ __typename
+ avatarUrl
+ colorScheme
+ createdAt
+ dateFormat
+ deletedAt
+ id
+ locale
+ name {
+ firstName
+ lastName
+ }
+ timeFormat
+ timeZone
+ updatedAt
+ userEmail
+ userId
+ }
workspaceMemberId
- name
- }
- amount {
- amountMicros
- currencyCode
- }
- stage
- position
- closeDate
- id
- name
- pointOfContactId
- companyId
- updatedAt
- deletedAt
- createdAt
- }
}
}
`,
diff --git a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx
index 3a30077ea285..9c984ececa94 100644
--- a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx
+++ b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx
@@ -1,16 +1,14 @@
-import { MockedProvider } from '@apollo/client/testing';
import { DropResult, ResponderProvided } from '@hello-pangea/dnd';
import { act, renderHook, waitFor } from '@testing-library/react';
-import { ReactNode } from 'react';
-import { RecoilRoot, useSetRecoilState } from 'recoil';
+import { useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
-import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope';
-import { generatedMockObjectMetadataItems } from '~/testing/mock-data/objectMetadataItems';
+import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
+import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import {
favoriteId,
favoriteTargetObjectRecord,
@@ -29,15 +27,9 @@ jest.mock('@/object-record/hooks/useFindManyRecords', () => ({
useFindManyRecords: () => ({ records: initialFavorites }),
}));
-const Wrapper = ({ children }: { children: ReactNode }) => (
-
-
-