diff --git a/nx.json b/nx.json index 695d3acc3f8..e1cd347e778 100644 --- a/nx.json +++ b/nx.json @@ -1,7 +1,12 @@ { - "workspaceLayout": { "appsDir": "packages", "libsDir": "packages" }, + "workspaceLayout": { + "appsDir": "packages", + "libsDir": "packages" + }, "namedInputs": { - "default": ["{projectRoot}/**/*"], + "default": [ + "{projectRoot}/**/*" + ], "excludeStories": [ "default", "!{projectRoot}/.storybook/*", @@ -29,24 +34,42 @@ "targetDefaults": { "build": { "cache": true, - "inputs": ["^production", "production"], - "dependsOn": ["^build"] + "inputs": [ + "^production", + "production" + ], + "dependsOn": [ + "^build" + ] + }, + "start": { + "cache": true, + "dependsOn": [ + "^build" + ] }, - "start": { "cache": true, "dependsOn": ["^build"] }, "lint": { "executor": "@nx/eslint:lint", "cache": true, - "outputs": ["{options.outputFile}"], + "outputs": [ + "{options.outputFile}" + ], "options": { "eslintConfig": "{projectRoot}/eslint.config.js", "cache": true, "cacheLocation": "{workspaceRoot}/.cache/eslint" }, "configurations": { - "ci": { "cacheStrategy": "content" }, - "fix": { "fix": true } + "ci": { + "cacheStrategy": "content" + }, + "fix": { + "fix": true + } }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "fmt": { "executor": "nx:run-commands", @@ -60,10 +83,16 @@ "write": false }, "configurations": { - "ci": { "cacheStrategy": "content" }, - "fix": { "write": true } + "ci": { + "cacheStrategy": "content" + }, + "fix": { + "write": true + } }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "typecheck": { "executor": "nx:run-commands", @@ -72,49 +101,85 @@ "cwd": "{projectRoot}", "command": "tsc -b tsconfig.json --incremental" }, - "configurations": { "watch": { "watch": true } }, - "dependsOn": ["^build"] + "configurations": { + "watch": { + "watch": true + } + }, + "dependsOn": [ + "^build" + ] }, "test": { "executor": "@nx/jest:jest", "cache": true, - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "inputs": [ "^default", "excludeStories", "{workspaceRoot}/jest.preset.js" ], - "outputs": ["{projectRoot}/coverage"], + "outputs": [ + "{projectRoot}/coverage" + ], "options": { "jestConfig": "{projectRoot}/jest.config.ts", "coverage": true, - "coverageReporters": ["text-summary"], + "coverageReporters": [ + "text-summary" + ], "cacheDirectory": "../../.cache/jest/{projectRoot}" }, "configurations": { - "ci": { "ci": true, "maxWorkers": 3 }, - "coverage": { "coverageReporters": ["lcov", "text"] }, - "watch": { "watch": true } + "ci": { + "ci": true, + "maxWorkers": 3 + }, + "coverage": { + "coverageReporters": [ + "lcov", + "text" + ] + }, + "watch": { + "watch": true + } } }, - "test:e2e": { "cache": true, "dependsOn": ["^build"] }, + "test:e2e": { + "cache": true, + "dependsOn": [ + "^build" + ] + }, "storybook:build": { "executor": "nx:run-commands", "cache": true, - "inputs": ["^default", "excludeTests"], - "outputs": ["{projectRoot}/{options.output-dir}"], + "inputs": [ + "^default", + "excludeTests" + ], + "outputs": [ + "{projectRoot}/{options.output-dir}" + ], "options": { "cwd": "{projectRoot}", "command": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true storybook build --test", "output-dir": "storybook-static", "config-dir": ".storybook" }, - "dependsOn": ["^build"] + "dependsOn": [ + "^build" + ] }, "storybook:serve:dev": { "executor": "nx:run-commands", "cache": true, - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "options": { "cwd": "{projectRoot}", "command": "storybook dev", @@ -123,7 +188,9 @@ }, "storybook:serve:static": { "executor": "nx:run-commands", - "dependsOn": ["storybook:build"], + "dependsOn": [ + "storybook:build" + ], "options": { "cwd": "{projectRoot}", "command": "npx http-server {args.staticDir} -a={args.host} --port={args.port} --silent={args.silent}", @@ -136,8 +203,13 @@ "storybook:test": { "executor": "nx:run-commands", "cache": true, - "inputs": ["^default", "excludeTests"], - "outputs": ["{projectRoot}/coverage/storybook"], + "inputs": [ + "^default", + "excludeTests" + ], + "outputs": [ + "{projectRoot}/coverage/storybook" + ], "options": { "cwd": "{projectRoot}", "commands": [ @@ -153,7 +225,10 @@ }, "storybook:test:no-coverage": { "executor": "nx:run-commands", - "inputs": ["^default", "excludeTests"], + "inputs": [ + "^default", + "excludeTests" + ], "options": { "cwd": "{projectRoot}", "commands": [ @@ -180,7 +255,11 @@ "reporter": "lcov", "checkCoverage": true }, - "configurations": { "text": { "reporter": "text" } } + "configurations": { + "text": { + "reporter": "text" + } + } }, "storybook:serve-and-test:static": { "executor": "nx:run-commands", @@ -206,7 +285,11 @@ ], "parallel": false }, - "configurations": { "ci": { "ci": "--exit-zero-on-changes" } } + "configurations": { + "ci": { + "ci": "--exit-zero-on-changes" + } + } }, "@nx/jest:jest": { "cache": true, @@ -215,8 +298,15 @@ "excludeStories", "{workspaceRoot}/jest.preset.js" ], - "options": { "passWithNoTests": true }, - "configurations": { "ci": { "ci": true, "codeCoverage": true } } + "options": { + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } }, "@nx/eslint:lint": { "cache": true, @@ -226,14 +316,27 @@ "{workspaceRoot}/tools/eslint-rules/**/*" ] }, - "@nx/vite:test": { "cache": true, "inputs": ["default", "^default"] }, + "@nx/vite:test": { + "cache": true, + "inputs": [ + "default", + "^default" + ] + }, "@nx/vite:build": { "cache": true, - "dependsOn": ["^build"], - "inputs": ["default", "^default"] + "dependsOn": [ + "^build" + ], + "inputs": [ + "default", + "^default" + ] } }, - "installation": { "version": "21.3.11" }, + "installation": { + "version": "21.3.11" + }, "generators": { "@nx/react": { "application": { @@ -252,12 +355,20 @@ "unitTestRunner": "jest", "projectNameAndRootFormat": "derived" }, - "component": { "style": "@emotion/styled" } + "component": { + "style": "@emotion/styled" + } } }, "tasksRunnerOptions": { - "default": { "options": { "cacheableOperations": ["storybook:build"] } } + "default": { + "options": { + "cacheableOperations": [ + "storybook:build" + ] + } + } }, "useInferencePlugins": false, "defaultBase": "main" -} +} \ No newline at end of file diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index 1581529801e..6ec83c4d5f6 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { type JestConfigWithTsJest, pathsToModuleNameMapper } from 'ts-jest'; +import { pathsToModuleNameMapper, type JestConfigWithTsJest } from 'ts-jest'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -58,8 +58,8 @@ const jestConfig: JestConfigWithTsJest = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 56, - lines: 55, + statements: 55, + lines: 54, functions: 45, }, }, diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index f006e756669..c052c3dfd0d 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -31,6 +31,7 @@ import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useIsRecordFieldReadOnly } from '@/object-record/record-field/hooks/read-only/useIsRecordFieldReadOnly'; import { isInlineCellInEditModeFamilyState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeFamilyState'; import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; +import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; @@ -369,13 +370,26 @@ export const ActivityRichTextEditor = ({ const recordTitleCellId = getRecordFieldInputInstanceId({ recordId: activityId, - fieldName: labelIdentifierFieldMetadataItem?.id, - prefix: 'activity-rich-text-editor', + fieldName: labelIdentifierFieldMetadataItem?.name, + // TODO: see comments below, this is a very temporary fix, + // it won't work for the breadcrumb title input, but that's ok for now. + prefix: RecordTitleCellContainerType.ShowPage, }); + // TODO: Here instead of closing the input, as it was intially planned, we should block if there is anything open, + // This information should be derived from the focus stack + // The problem with this library is that it takes the focus before anything else and does not prevent the event from bubbling + // Because of this, other events listen at the same time, and when we're in luck, the click outside gets triggered, + // but this leaves the door open for unpredicted behavior with click handlers conflicts, + // we recently had a bug which was deleting what the user typed and closed the right drawer if he used backspace key. + // We could maybe use the types of components in the focus stack. const handleBlockEditorFocus = useRecoilCallback( ({ snapshot }) => () => { + // TODO: Here we want to detect anything that is open to avoid conflicts with the library click event + // that is not prevented and propagate to other click handlers in the app. + // Because that is how we do in the app, for example with stacked dropdowns, we always close what's open before + // letting the click being captured by a button or input that can capture it. const isRecordTitleCellOpen = snapshot .getLoadable(isInlineCellInEditModeFamilyState(recordTitleCellId)) .getValue(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index 37e8cf35e88..294304e6671 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -13,7 +13,6 @@ import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/ import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; -import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput'; import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput'; import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; @@ -40,147 +39,51 @@ import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInpu import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInput'; import { RelationToOneFieldInput } from '../meta-types/input/components/RelationToOneFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; -import { type FieldInputEvent } from '../types/FieldInputEvent'; -import { type FieldRichTextV2Metadata } from '../types/FieldMetadata'; import { isFieldText } from '../types/guards/isFieldText'; -type FieldInputProps = { - onSubmit?: FieldInputEvent; - onCancel?: () => void; - onClickOutside?: ( - persist: () => void, - event: MouseEvent | TouchEvent, - ) => void; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; - isReadOnly?: boolean; -}; - -export const FieldInput = ({ - onCancel, - onSubmit, - onEnter, - onEscape, - onShiftTab, - onTab, - onClickOutside, - isReadOnly, -}: FieldInputProps) => { - const { fieldDefinition, recordId } = useContext(FieldContext); +export const FieldInput = () => { + const { fieldDefinition } = useContext(FieldContext); return ( <> {isFieldRelationToOneObject(fieldDefinition) ? ( - + ) : isFieldRelationFromManyObjects(fieldDefinition) ? ( - + ) : isFieldPhones(fieldDefinition) ? ( - + ) : isFieldText(fieldDefinition) ? ( - + ) : isFieldEmails(fieldDefinition) ? ( - onClickOutside?.(() => {}, event)} - /> + ) : isFieldFullName(fieldDefinition) ? ( - + ) : isFieldDateTime(fieldDefinition) ? ( - + ) : isFieldDate(fieldDefinition) ? ( - + ) : isFieldNumber(fieldDefinition) ? ( - + ) : isFieldLinks(fieldDefinition) ? ( - onClickOutside?.(() => {}, event)} - /> + ) : isFieldCurrency(fieldDefinition) ? ( - + ) : isFieldBoolean(fieldDefinition) ? ( - + ) : isFieldRating(fieldDefinition) ? ( - + ) : isFieldSelect(fieldDefinition) ? ( - + ) : isFieldMultiSelect(fieldDefinition) ? ( - + ) : isFieldAddress(fieldDefinition) ? ( - + ) : isFieldRawJson(fieldDefinition) ? ( - + ) : isFieldArray(fieldDefinition) ? ( - onClickOutside?.(() => {}, event)} - /> + ) : isFieldRichTextV2(fieldDefinition) ? ( - + ) : ( <> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldInputEventContext.ts b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldInputEventContext.ts new file mode 100644 index 00000000000..383730a291d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/contexts/FieldInputEventContext.ts @@ -0,0 +1,28 @@ +import { createContext } from 'react'; + +export type FieldInputEventArgs = { + newValue?: unknown; + skipPersist?: boolean; +}; + +export type FieldInputEvent = (args: FieldInputEventArgs) => void; + +export type FieldInputClickOutsideEvent = (args: { + newValue?: unknown; + skipPersist?: boolean; + event?: MouseEvent | TouchEvent; +}) => void; + +export type FieldInputEventContextType = { + onEnter?: FieldInputEvent; + onCancel?: () => void; + onEscape?: FieldInputEvent; + onSubmit?: FieldInputEvent; + onTab?: FieldInputEvent; + onShiftTab?: FieldInputEvent; + onClickOutside?: FieldInputClickOutsideEvent; +}; + +export const FieldInputEventContext = createContext( + {} as FieldInputEventContextType, +); diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx deleted file mode 100644 index cabbf769184..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments'; -import { responseData } from '@/object-record/hooks/__mocks__/useUpdateOneRecord'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { - phonesFieldDefinition, - relationFieldDefinition, -} from '@/object-record/record-field/__mocks__/fieldDefinitions'; -import { - FieldContext, - type RecordUpdateHook, - type RecordUpdateHookParams, -} from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; -import { type FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; -import { type FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -import { gql } from '@apollo/client'; -import { type MockedResponse } from '@apollo/client/testing'; -import { renderHook, waitFor } from '@testing-library/react'; -import { type ReactNode, act } from 'react'; -import { useRecoilValue } from 'recoil'; -import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; - -const query = gql` - mutation UpdateOnePerson($idToUpdate: UUID!, $input: PersonUpdateInput!) { - updatePerson(id: $idToUpdate, data: $input) { - ${PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS} - } - } -`; - -const phoneMock = { - primaryPhoneNumber: '123 456', - primaryPhoneCountryCode: 'US', - primaryPhoneCallingCode: '+1', - additionalPhones: [], -}; - -const mockPersonResponse = { ...responseData, id: 'recordId' }; - -const mocks: MockedResponse[] = [ - { - request: { - query, - variables: { - idToUpdate: 'recordId', - input: { - phones: phoneMock, - }, - }, - }, - result: jest.fn(() => ({ - data: { - updatePerson: mockPersonResponse, - }, - })), - }, - { - request: { - query, - variables: { - idToUpdate: 'recordId', - input: { companyId: 'companyId' }, - }, - }, - result: jest.fn(() => ({ - data: { - updatePerson: mockPersonResponse, - }, - })), - }, -]; - -const recordId = 'recordId'; - -const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({ - apolloMocks: mocks, -}); - -const getWrapper = - (fieldDefinition: FieldDefinition) => - ({ children }: { children: ReactNode }) => { - const useUpdateOneRecordMutation: RecordUpdateHook = () => { - const { updateOneRecord } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Person, - }); - - const updateEntity = ({ variables }: RecordUpdateHookParams) => { - updateOneRecord?.({ - idToUpdate: variables.where.id as string, - updateOneRecordInput: variables.updateOneRecordInput, - }); - }; - - return [updateEntity, { loading: false }]; - }; - - return ( - - - {children} - - - ); - }; - -const PhoneWrapper = getWrapper(phonesFieldDefinition); -const RelationWrapper = getWrapper(relationFieldDefinition); - -describe('usePersistField', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should work as expected', async () => { - const { result } = renderHook( - () => { - const entityFields = useRecoilValue( - recordStoreFamilySelector({ recordId, fieldName: 'phones' }), - ); - - return { - persistField: usePersistField(), - entityFields, - }; - }, - { wrapper: PhoneWrapper }, - ); - - act(() => { - result.current.persistField({ - primaryPhoneNumber: '123 456', - primaryPhoneCountryCode: 'US', - primaryPhoneCallingCode: '+1', - additionalPhones: [], - }); - }); - - await waitFor(() => { - expect(mocks[0].result).toHaveBeenCalled(); - }); - }); - - it('should persist relation field', async () => { - const { result } = renderHook( - () => { - const entityFields = useRecoilValue( - recordStoreFamilySelector({ - recordId, - fieldName: 'company', - }), - ); - - return { - persistField: usePersistField(), - entityFields, - }; - }, - { wrapper: RelationWrapper }, - ); - - act(() => { - result.current.persistField({ id: 'companyId' }); - }); - - await waitFor(() => { - expect(mocks[1].result).toHaveBeenCalled(); - }); - }); - - it('should skip persistence value has not changed', async () => { - const { result } = renderHook( - () => { - const entityFields = useRecoilValue( - recordStoreFamilySelector({ recordId, fieldName: 'phones' }), - ); - - return { - persistField: usePersistField(), - entityFields, - }; - }, - { wrapper: PhoneWrapper }, - ); - - act(() => { - result.current.persistField(phoneMock); - }); - - await waitFor(() => { - expect(mocks[0].result).not.toHaveBeenCalled(); - }); - }); - - it('should skip persistence when relation field value has not changed', async () => { - const { result } = renderHook( - () => { - const entityFields = useRecoilValue( - recordStoreFamilySelector({ - recordId, - fieldName: 'company', - }), - ); - - return { - persistField: usePersistField(), - entityFields, - }; - }, - { wrapper: RelationWrapper }, - ); - - act(() => { - result.current.persistField({ id: 'companyId' }); - }); - - await waitFor(() => { - expect(mocks[1].result).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index ed1377d3b6f..e9b38e44b9d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -1,8 +1,10 @@ -import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; import { type FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; -import { type FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { + type FieldMetadata, + type FieldRelationMetadata, +} from '@/object-record/record-field/types/FieldMetadata'; import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; @@ -25,6 +27,8 @@ import { isFieldSelect } from '@/object-record/record-field/types/guards/isField import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { isWorkflowRunJsonField } from '@/object-record/record-field/meta-types/utils/isWorkflowRunJsonField'; import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; import { isFieldArrayValue } from '@/object-record/record-field/types/guards/isFieldArrayValue'; @@ -34,7 +38,6 @@ import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/ import { isFieldRichTextV2Value } from '@/object-record/record-field/types/guards/isFieldRichTextValueV2'; import { getForeignKeyNameFromRelationFieldName } from '@/object-record/utils/getForeignKeyNameFromRelationFieldName'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { FieldContext } from '../contexts/FieldContext'; import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldBooleanValue } from '../types/guards/isFieldBooleanValue'; import { isFieldCurrency } from '../types/guards/isFieldCurrency'; @@ -48,18 +51,30 @@ import { isFieldRatingValue } from '../types/guards/isFieldRatingValue'; import { isFieldText } from '../types/guards/isFieldText'; import { isFieldTextValue } from '../types/guards/isFieldTextValue'; -export const usePersistField = () => { - const { - recordId, - fieldDefinition, - useUpdateRecord = () => [], - } = useContext(FieldContext); +export const usePersistField = ({ + objectMetadataItemId, +}: { + objectMetadataItemId: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: objectMetadataItemId, + }); - const [updateRecord] = useUpdateRecord(); + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: objectMetadataItem?.nameSingular ?? '', + }); const persistField = useRecoilCallback( ({ set, snapshot }) => - (valueToPersist: unknown) => { + ({ + recordId, + fieldDefinition, + valueToPersist, + }: { + recordId: string; + fieldDefinition: FieldDefinition; + valueToPersist: unknown; + }) => { const fieldIsRelationToOneObject = isFieldRelationToOneObject( fieldDefinition as FieldDefinition, @@ -183,24 +198,20 @@ export const usePersistField = () => { ); if (fieldIsRelationToOneObject) { - updateRecord?.({ - variables: { - where: { id: recordId }, - updateOneRecordInput: { - [getForeignKeyNameFromRelationFieldName(fieldName)]: - valueToPersist?.id ?? null, - }, + updateOneRecord?.({ + idToUpdate: recordId, + updateOneRecordInput: { + [getForeignKeyNameFromRelationFieldName(fieldName)]: + valueToPersist?.id ?? null, }, }); return; } - updateRecord?.({ - variables: { - where: { id: recordId }, - updateOneRecordInput: { - [fieldName]: valueToPersist, - }, + updateOneRecord?.({ + idToUpdate: recordId, + updateOneRecordInput: { + [fieldName]: valueToPersist, }, }); } else { @@ -213,7 +224,7 @@ export const usePersistField = () => { ); } }, - [recordId, fieldDefinition, updateRecord], + [updateOneRecord], ); return persistField; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistFieldFromFieldInputContext.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistFieldFromFieldInputContext.ts new file mode 100644 index 00000000000..e2cf9e812e9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistFieldFromFieldInputContext.ts @@ -0,0 +1,32 @@ +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { useContext } from 'react'; + +export const usePersistFieldFromFieldInputContext = () => { + const { fieldDefinition, recordId } = useContext(FieldContext); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItemToFind) => + objectMetadataItemToFind.fields.some( + (fieldMetadataItemToFind) => + fieldMetadataItemToFind.id === fieldDefinition.fieldMetadataId, + ), + ); + + const persistField = usePersistField({ + objectMetadataItemId: objectMetadataItem?.id ?? '', + }); + + const persistFieldFromFieldInputContext = (valueToPersist: unknown) => { + persistField({ + recordId, + fieldDefinition, + valueToPersist, + }); + }; + + return { persistFieldFromFieldInputContext }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordFieldInput.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordFieldInput.ts index 2f555e8d726..a7aa011689d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordFieldInput.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useRecordFieldInput.ts @@ -1,10 +1,18 @@ import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { type FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; -import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilCallback } from 'recoil'; export const useRecordFieldInput = () => { - const setDraftValue = useSetRecoilComponentState( - recordFieldInputDraftValueComponentState, + const recordFieldInputDraftValueCallbackState = + useRecoilComponentCallbackState(recordFieldInputDraftValueComponentState); + + const setDraftValue = useRecoilCallback( + ({ set }) => + (newValue: unknown) => { + set(recordFieldInputDraftValueCallbackState, newValue); + }, + [recordFieldInputDraftValueCallbackState], ); const isDraftValueEmpty = ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts index eab63f7d49b..89c36cfd985 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts @@ -2,14 +2,12 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; -import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { FieldContext } from '../../contexts/FieldContext'; -import { usePersistField } from '../../hooks/usePersistField'; import { type FieldAddressValue } from '../../types/FieldMetadata'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; import { isFieldAddress } from '../../types/guards/isFieldAddress'; @@ -32,16 +30,6 @@ export const useAddressField = () => { }), ); - const persistField = usePersistField(); - - const persistAddressField = (newValue: FieldAddressValue) => { - if (!isFieldAddressValue(newValue)) { - return; - } - - persistField(newValue); - }; - const { setDraftValue } = useRecordFieldInput(); const draftValue = useRecoilComponentValue( @@ -54,6 +42,5 @@ export const useAddressField = () => { setFieldValue, draftValue, setDraftValue, - persistAddressField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts index 73f3a9268e6..ae5f68b6eb7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useArrayField.ts @@ -1,10 +1,11 @@ import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { type FieldArrayValue } from '@/object-record/record-field/types/FieldMetadata'; import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; -import { arraySchema } from '@/object-record/record-field/types/guards/isFieldArrayValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -23,22 +24,17 @@ export const useArrayField = () => { }), ); - const persistField = usePersistField(); + const { setDraftValue } = useRecordFieldInput(); - const persistArrayField = (nextValue: string[]) => { - if (!nextValue) persistField(null); - - try { - persistField(arraySchema.parse(nextValue)); - } catch { - return; - } - }; + const draftValue = useRecoilComponentValue( + recordFieldInputDraftValueComponentState, + ); return { fieldValue, fieldDefinition, setFieldValue, - persistArrayField, + draftValue, + setDraftValue, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts index c65b3868587..155f1717645 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts @@ -2,17 +2,14 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { type FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; -import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; export const useCurrencyField = () => { const { recordId, fieldDefinition } = useContext(FieldContext); @@ -32,30 +29,6 @@ export const useCurrencyField = () => { }), ); - const persistField = usePersistField(); - - const persistCurrencyField = ({ - amountText, - currencyCode, - }: { - amountText: string; - currencyCode: string; - }) => { - const amount = parseFloat(amountText); - - const newCurrencyValue = { - amountMicros: isNaN(amount) - ? null - : convertCurrencyAmountToCurrencyMicros(amount), - currencyCode, - }; - - if (!isFieldCurrencyValue(newCurrencyValue)) { - return; - } - persistField(newCurrencyValue); - }; - const { setDraftValue } = useRecordFieldInput(); const draftValue = useRecoilComponentValue( @@ -70,7 +43,6 @@ export const useCurrencyField = () => { draftValue, setDraftValue, setFieldValue, - persistCurrencyField, defaultValue, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts index 675a4548a87..f4ea3553ef8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts @@ -1,11 +1,9 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { type FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails'; -import { emailsSchema } from '@/object-record/record-field/types/guards/isFieldEmailsValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -34,22 +32,11 @@ export const useEmailsField = () => { recordFieldInputDraftValueComponentState, ); - const persistField = usePersistField(); - - const persistEmailsField = (nextValue: FieldEmailsValue) => { - try { - persistField(emailsSchema.parse(nextValue)); - } catch { - return; - } - }; - return { fieldDefinition, fieldValue, draftValue, setDraftValue, setFieldValue, - persistEmailsField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts index 89b1479d7c8..615f2887578 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts @@ -2,13 +2,11 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { type FieldFullNameValue } from '@/object-record/record-field/types/FieldMetadata'; import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; -import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -31,16 +29,6 @@ export const useFullNameField = () => { }), ); - const persistField = usePersistField(); - - const persistFullNameField = (newValue: FieldFullNameValue) => { - if (!isFieldFullNameValue(newValue)) { - return; - } - - persistField(newValue); - }; - const { setDraftValue } = useRecordFieldInput(); const draftValue = useRecoilComponentValue( @@ -53,6 +41,5 @@ export const useFullNameField = () => { setFieldValue, draftValue, setDraftValue, - persistFullNameField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts index d238c2a976a..41f63a1716d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts @@ -1,7 +1,6 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { type FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; @@ -32,18 +31,6 @@ export const useJsonField = () => { }), ); - const persistField = usePersistField(); - - const persistJsonField = (nextValue: string) => { - if (!nextValue) persistField(null); - - try { - persistField(JSON.parse(nextValue)); - } catch { - // Do nothing - } - }; - const { setDraftValue } = useRecordFieldInput(); const draftValue = useRecoilComponentValue( @@ -62,6 +49,5 @@ export const useJsonField = () => { fieldDefinition, fieldValue, setFieldValue, - persistJsonField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts index 4f6d1c3997d..3cfb7d7446d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinksField.ts @@ -1,11 +1,9 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { type FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; -import { linksSchema } from '@/object-record/record-field/types/guards/isFieldLinksValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -34,22 +32,11 @@ export const useLinksField = () => { recordFieldInputDraftValueComponentState, ); - const persistField = usePersistField(); - - const persistLinksField = (nextValue: FieldLinksValue) => { - try { - persistField(linksSchema.parse(nextValue)); - } catch { - return; - } - }; - return { fieldDefinition, fieldValue, draftValue, setDraftValue, setFieldValue, - persistLinksField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts index 9e4eed5192e..d06ea3ee103 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts @@ -2,7 +2,6 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { type FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata'; @@ -34,7 +33,6 @@ export const useMultiSelectField = () => { const fieldMultiSelectValues = isFieldMultiSelectValue(fieldValues) ? fieldValues : null; - const persistField = usePersistField(); const { setDraftValue } = useRecordFieldInput(); @@ -45,7 +43,6 @@ export const useMultiSelectField = () => { return { recordId, fieldDefinition, - persistField, fieldValues: fieldMultiSelectValues, draftValue, setDraftValue, 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 b68be9a938f..fae0991254b 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 @@ -6,16 +6,9 @@ import { type FieldNumberValue } from '@/object-record/record-field/types/FieldM import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { - canBeCastAsNumberOrNull, - castAsNumberOrNull, -} from '~/utils/cast-as-number-or-null'; - import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { isNull } from '@sniptt/guards'; import { FieldContext } from '../../contexts/FieldContext'; -import { usePersistField } from '../../hooks/usePersistField'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; import { isFieldNumber } from '../../types/guards/isFieldNumber'; @@ -33,29 +26,6 @@ export const useNumberField = () => { }), ); - const persistField = usePersistField(); - - const persistNumberField = (newValue: string) => { - if (fieldDefinition?.metadata?.settings?.type === 'percentage') { - const newValueEscaped = newValue.replaceAll('%', ''); - if (!canBeCastAsNumberOrNull(newValueEscaped)) { - return; - } - const castedValue = castAsNumberOrNull(newValue); - if (!isNull(castedValue)) { - persistField(castedValue / 100); - return; - } - persistField(null); - return; - } - if (!canBeCastAsNumberOrNull(newValue)) { - return; - } - const castedValue = castAsNumberOrNull(newValue); - persistField(castedValue); - }; - const { setDraftValue } = useRecordFieldInput(); const draftValue = useRecoilComponentValue( @@ -68,6 +38,5 @@ export const useNumberField = () => { draftValue, setDraftValue, setFieldValue, - persistNumberField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts index 9625ccbfdc1..ab3e40f4e74 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts @@ -1,11 +1,9 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { type FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones'; -import { phonesSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -34,22 +32,11 @@ export const usePhonesField = () => { recordFieldInputDraftValueComponentState, ); - const persistField = usePersistField(); - - const persistPhonesField = (nextValue: FieldPhonesValue) => { - try { - persistField(phonesSchema.parse(nextValue)); - } catch { - return; - } - }; - return { fieldDefinition, fieldValue, draftValue, setDraftValue, setFieldValue, - persistPhonesField, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextField.ts deleted file mode 100644 index b32ee965a7d..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextField.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useContext } from 'react'; -import { useRecoilState } from 'recoil'; - -import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; -import { type FieldRichTextValue } from '@/object-record/record-field/types/FieldMetadata'; -import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; -import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; -import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; -import { isFieldRichTextValue } from '@/object-record/record-field/types/guards/isFieldRichTextValue'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import type { PartialBlock } from '@blocknote/core'; -import { isNonEmptyString } from '@sniptt/guards'; -import { FieldContext } from '../../contexts/FieldContext'; -import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; - -export const useRichTextField = () => { - const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext); - - assertFieldMetadata( - FieldMetadataType.RICH_TEXT, - isFieldRichText, - fieldDefinition, - ); - - const fieldName = fieldDefinition.metadata.fieldName; - - const [fieldValue, setFieldValue] = useRecoilState( - recordStoreFamilySelector({ - recordId, - fieldName: fieldName, - }), - ); - const fieldRichTextValue = isFieldRichTextValue(fieldValue) ? fieldValue : ''; - - const { setDraftValue } = useRecordFieldInput(); - - const draftValue = useRecoilComponentValue( - recordFieldInputDraftValueComponentState, - ); - - const draftValueParsed: PartialBlock[] = isNonEmptyString(draftValue) - ? JSON.parse(draftValue) - : draftValue; - - const persistField = usePersistField(); - - const persistRichTextField = (nextValue: PartialBlock[]) => { - if (!nextValue) { - persistField(null); - } else { - const parsedValueToPersist = JSON.stringify(nextValue); - - persistField(parsedValueToPersist); - } - }; - - return { - draftValue: draftValueParsed, - setDraftValue, - maxWidth, - fieldDefinition, - fieldValue: fieldRichTextValue, - setFieldValue, - persistRichTextField, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextV2Field.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextV2Field.ts deleted file mode 100644 index 5d7d9641297..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRichTextV2Field.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useContext } from 'react'; -import { useRecoilState } from 'recoil'; - -import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; -import { - type FieldRichTextV2Value, - type FieldRichTextValue, -} from '@/object-record/record-field/types/FieldMetadata'; -import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; -import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; -import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2'; -import { isFieldRichTextV2Value } from '@/object-record/record-field/types/guards/isFieldRichTextValueV2'; -import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import type { PartialBlock } from '@blocknote/core'; -import { isNonEmptyString } from '@sniptt/guards'; -import { FieldContext } from '../../contexts/FieldContext'; -import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; - -export const useRichTextV2Field = () => { - const { recordId, fieldDefinition, maxWidth } = useContext(FieldContext); - - assertFieldMetadata( - FieldMetadataType.RICH_TEXT_V2, - isFieldRichTextV2, - fieldDefinition, - ); - - const fieldName = fieldDefinition.metadata.fieldName; - - const [fieldValue, setFieldValue] = useRecoilState( - recordStoreFamilySelector({ - recordId, - fieldName: fieldName, - }), - ); - const fieldRichTextV2Value = isFieldRichTextV2Value(fieldValue) - ? fieldValue - : ({ blocknote: null, markdown: null } as FieldRichTextV2Value); - - const { setDraftValue } = useRecordFieldInput(); - - const draftValue = useRecoilComponentValue( - recordFieldInputDraftValueComponentState, - ); - - const draftValueParsed: PartialBlock[] = isNonEmptyString(draftValue) - ? JSON.parse(draftValue) - : draftValue; - - const persistField = usePersistField(); - - const persistRichTextField = (nextValue: PartialBlock[]) => { - if (!nextValue) { - persistField(null); - } else { - const parsedValueToPersist = JSON.stringify(nextValue); - - persistField(parsedValueToPersist); - } - }; - - return { - draftValue: draftValueParsed, - setDraftValue, - maxWidth, - fieldDefinition, - fieldValue: fieldRichTextV2Value, - setFieldValue, - persistRichTextField, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useSelectField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useSelectField.ts index 3f313cc334e..89144c29b71 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useSelectField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useSelectField.ts @@ -1,7 +1,6 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -29,7 +28,6 @@ export const useSelectField = () => { ); const fieldSelectValue = isFieldSelectValue(fieldValue) ? fieldValue : null; - const persistField = usePersistField(); const { setDraftValue } = useRecordFieldInput(); @@ -40,7 +38,6 @@ export const useSelectField = () => { return { recordId, fieldDefinition, - persistField, fieldValue: fieldSelectValue, draftValue, setDraftValue, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx index aeeced43c85..9b13ed96591 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx @@ -3,31 +3,17 @@ import { type FieldAddressDraftValue } from '@/object-record/record-field/types/ import { AddressInput } from '@/ui/field/input/components/AddressInput'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { usePersistField } from '../../../hooks/usePersistField'; +import { useContext } from 'react'; -export type AddressFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; - -export const AddressFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: AddressFieldInputProps) => { +export const AddressFieldInput = () => { const { draftValue, setDraftValue, fieldDefinition } = useAddressField(); - const persistField = usePersistField(); + const { onEnter, onTab, onShiftTab, onEscape, onClickOutside } = useContext( + FieldInputEventContext, + ); const convertToAddress = ( newAddress: FieldAddressDraftValue | undefined, @@ -47,27 +33,28 @@ export const AddressFieldInput = ({ const subFields = settings && 'subFields' in settings ? settings.subFields : undefined; + const handleEnter = (newAddress: FieldAddressDraftValue) => { - onEnter?.(() => persistField(convertToAddress(newAddress))); + onEnter?.({ newValue: convertToAddress(newAddress) }); }; const handleTab = (newAddress: FieldAddressDraftValue) => { - onTab?.(() => persistField(convertToAddress(newAddress))); + onTab?.({ newValue: convertToAddress(newAddress) }); }; const handleShiftTab = (newAddress: FieldAddressDraftValue) => { - onShiftTab?.(() => persistField(convertToAddress(newAddress))); + onShiftTab?.({ newValue: convertToAddress(newAddress) }); }; const handleEscape = (newAddress: FieldAddressDraftValue) => { - onEscape?.(() => persistField(convertToAddress(newAddress))); + onEscape?.({ newValue: convertToAddress(newAddress) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newAddress: FieldAddressDraftValue, ) => { - onClickOutside?.(() => persistField(convertToAddress(newAddress)), event); + onClickOutside?.({ newValue: convertToAddress(newAddress), event }); }; const handleChange = (newAddress: FieldAddressDraftValue) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx index 288880831ea..087e366ae3e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/ArrayFieldInput.tsx @@ -1,35 +1,50 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useArrayField } from '@/object-record/record-field/meta-types/hooks/useArrayField'; import { ArrayFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/ArrayFieldMenuItem'; import { MultiItemFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiItemFieldInput'; -import { useMemo } from 'react'; +import { arraySchema } from '@/object-record/record-field/types/guards/isFieldArrayValue'; +import { useContext, useMemo } from 'react'; +import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -type ArrayFieldInputProps = { - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; -}; +export const ArrayFieldInput = () => { + const { setDraftValue, draftValue, fieldDefinition } = useArrayField(); -export const ArrayFieldInput = ({ - onCancel, - onClickOutside, -}: ArrayFieldInputProps) => { - const { persistArrayField, fieldValue, fieldDefinition } = useArrayField(); + const { onEscape, onClickOutside } = useContext(FieldInputEventContext); const arrayItems = useMemo>( - () => (Array.isArray(fieldValue) ? fieldValue : []), - [fieldValue], + () => (Array.isArray(draftValue) ? draftValue : []), + [draftValue], ); + const handleChange = (newValue: any[]) => { + if (!isDefined(newValue)) setDraftValue(null); + + const parseResponse = arraySchema.safeParse(newValue); + + if (parseResponse.success) { + setDraftValue(parseResponse.data); + } + }; + + const handleClickOutside = ( + _newValue: any, + event: MouseEvent | TouchEvent, + ) => { + onClickOutside?.({ newValue: draftValue, event }); + }; + + const handleEscape = (_newValue: any) => { + onEscape?.({ newValue: draftValue }); + }; + return ( { - onClickOutside?.(event); - persist(); - }} + onChange={handleChange} + onEscape={handleEscape} + onClickOutside={handleClickOutside} placeholder="Enter value" fieldMetadataType={FieldMetadataType.ARRAY} renderItem={({ value, index, handleEdit, handleDelete }) => ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx index d4399e18e8a..ecf2e5c513f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx @@ -1,33 +1,30 @@ import { BooleanInput } from '@/ui/field/input/components/BooleanInput'; -import { type FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; -import { usePersistField } from '../../../hooks/usePersistField'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; +import { useContext } from 'react'; import { useBooleanField } from '../../hooks/useBooleanField'; export type BooleanFieldInputProps = { - onSubmit?: FieldInputEvent; - readonly?: boolean; testId?: string; }; -export const BooleanFieldInput = ({ - onSubmit, - readonly, - testId, -}: BooleanFieldInputProps) => { +export const BooleanFieldInput = ({ testId }: BooleanFieldInputProps) => { const { fieldValue } = useBooleanField(); - const persistField = usePersistField(); + const { isRecordFieldReadOnly } = useContext(FieldContext); + + const { onSubmit } = useContext(FieldInputEventContext); const handleToggle = (newValue: boolean) => { - onSubmit?.(() => persistField(newValue)); + onSubmit?.({ newValue }); }; return ( ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx index 7222301fef3..970d54e24e8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx @@ -6,31 +6,21 @@ import { CurrencyInput } from '@/ui/field/input/components/CurrencyInput'; import { useCurrencyField } from '../../hooks/useCurrencyField'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + +import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; +import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -type CurrencyFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; +export const CurrencyFieldInput = () => { + const { draftValue, setDraftValue, defaultValue } = useCurrencyField(); -export const CurrencyFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: CurrencyFieldInputProps) => { - const { draftValue, persistCurrencyField, setDraftValue, defaultValue } = - useCurrencyField(); + const { onClickOutside, onEnter, onEscape, onShiftTab, onTab } = useContext( + FieldInputEventContext, + ); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, @@ -55,21 +45,44 @@ export const CurrencyFieldInput = ({ ? defaultCurrencyCodeWithoutSQLQuotes : CurrencyCode.USD; + const getNewCurrencyValue = ({ + amountText, + currencyCode, + }: { + amountText: string; + currencyCode: string; + }) => { + const amount = parseFloat(amountText); + + const newCurrencyValue = { + amountMicros: isNaN(amount) + ? null + : convertCurrencyAmountToCurrencyMicros(amount), + currencyCode, + }; + + if (!isFieldCurrencyValue(newCurrencyValue)) { + return; + } + + return newCurrencyValue; + }; + const handleEnter = (newValue: string) => { - onEnter?.(() => { - persistCurrencyField({ + onEnter?.({ + newValue: getNewCurrencyValue({ amountText: newValue, currencyCode, - }); + }), }); }; const handleEscape = (newValue: string) => { - onEscape?.(() => { - persistCurrencyField({ + onEscape?.({ + newValue: getNewCurrencyValue({ amountText: newValue, currencyCode, - }); + }), }); }; @@ -77,30 +90,31 @@ export const CurrencyFieldInput = ({ event: MouseEvent | TouchEvent, newValue: string, ) => { - onClickOutside?.(() => { - persistCurrencyField({ + onClickOutside?.({ + newValue: getNewCurrencyValue({ amountText: newValue, currencyCode, - }); - }, event); + }), + event, + }); }; const handleTab = (newValue: string) => { - onTab?.(() => { - persistCurrencyField({ + onTab?.({ + newValue: getNewCurrencyValue({ amountText: newValue, currencyCode, - }); + }), }); }; const handleShiftTab = (newValue: string) => { - onShiftTab?.(() => - persistCurrencyField({ + onShiftTab?.({ + newValue: getNewCurrencyValue({ amountText: newValue, currencyCode, }), - ); + }); }; const handleChange = (newValue: string) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx index 1bd49853c2c..680a70241aa 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx @@ -1,41 +1,27 @@ import { useDateField } from '@/object-record/record-field/meta-types/hooks/useDateField'; import { DateInput } from '@/ui/field/input/components/DateInput'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { type Nullable } from 'twenty-ui/utilities'; -import { usePersistField } from '../../../hooks/usePersistField'; -type FieldInputEvent = (persist: () => void) => void; - -type DateFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onClear?: FieldInputEvent; - onSubmit?: FieldInputEvent; -}; - -export const DateFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onClear, - onSubmit, -}: DateFieldInputProps) => { +export const DateFieldInput = () => { const { fieldValue, setDraftValue } = useDateField(); + const { onEnter, onEscape, onClickOutside, onSubmit } = useContext( + FieldInputEventContext, + ); + const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); - const persistField = usePersistField(); - - const persistDate = (newDate: Nullable) => { + const getDateToPersist = (newDate: Nullable) => { if (!isDefined(newDate)) { - persistField(null); + return null; } else { const newDateWithoutTime = `${newDate?.getFullYear()}-${( newDate?.getMonth() + 1 @@ -43,27 +29,27 @@ export const DateFieldInput = ({ .toString() .padStart(2, '0')}-${newDate?.getDate().toString().padStart(2, '0')}`; - persistField(newDateWithoutTime); + return newDateWithoutTime; } }; const handleEnter = (newDate: Nullable) => { - onEnter?.(() => persistDate(newDate)); + onEnter?.({ newValue: getDateToPersist(newDate) }); }; const handleSubmit = (newDate: Nullable) => { - onSubmit?.(() => persistDate(newDate)); + onSubmit?.({ newValue: getDateToPersist(newDate) }); }; const handleEscape = (newDate: Nullable) => { - onEscape?.(() => persistDate(newDate)); + onEscape?.({ newValue: getDateToPersist(newDate) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newDate: Nullable, ) => { - onClickOutside?.(() => persistDate(newDate), event); + onClickOutside?.({ newValue: getDateToPersist(newDate), event }); }; const handleChange = (newDate: Nullable) => { @@ -71,7 +57,7 @@ export const DateFieldInput = ({ }; const handleClear = () => { - onClear?.(() => persistDate(null)); + onSubmit?.({ newValue: null }); }; const dateValue = fieldValue ? new Date(fieldValue) : null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx index 0b02ce9354e..f4fa9936cf2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx @@ -1,60 +1,46 @@ import { DateInput } from '@/ui/field/input/components/DateInput'; -import { type FieldInputEvent } from '@/object-record/record-field/meta-types/input/components/NumberFieldInput'; - +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; import { type Nullable } from 'twenty-ui/utilities'; -import { usePersistField } from '../../../hooks/usePersistField'; import { useDateTimeField } from '../../hooks/useDateTimeField'; -export type DateTimeFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onClear?: FieldInputEvent; - onSubmit?: FieldInputEvent; -}; - -export const DateTimeFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onClear, - onSubmit, -}: DateTimeFieldInputProps) => { +export const DateTimeFieldInput = () => { const { fieldValue, setDraftValue } = useDateTimeField(); + const { onEnter, onEscape, onClickOutside, onSubmit } = useContext( + FieldInputEventContext, + ); + const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); - const persistField = usePersistField(); - - const persistDate = (newDate: Nullable) => { + const getDateToPersist = (newDate: Nullable) => { if (!newDate) { - persistField(null); + return null; } else { const newDateISO = newDate?.toISOString(); - persistField(newDateISO); + return newDateISO; } }; const handleEnter = (newDate: Nullable) => { - onEnter?.(() => persistDate(newDate)); + onEnter?.({ newValue: getDateToPersist(newDate) }); }; const handleEscape = (newDate: Nullable) => { - onEscape?.(() => persistDate(newDate)); + onEscape?.({ newValue: getDateToPersist(newDate) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newDate: Nullable, ) => { - onClickOutside?.(() => persistDate(newDate), event); + onClickOutside?.({ newValue: getDateToPersist(newDate), event }); }; const handleChange = (newDate: Nullable) => { @@ -62,11 +48,11 @@ export const DateTimeFieldInput = ({ }; const handleClear = () => { - onClear?.(() => persistDate(null)); + onSubmit?.({ newValue: null }); }; const handleSubmit = (newDate: Nullable) => { - onSubmit?.(() => persistDate(newDate)); + onSubmit?.({ newValue: getDateToPersist(newDate) }); }; const dateValue = fieldValue ? new Date(fieldValue) : null; 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 396673740ca..1c7c3a869b3 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,43 +1,46 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField'; import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem'; import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState'; +import { emailsSchema } from '@/object-record/record-field/types/guards/isFieldEmailsValue'; import { emailSchema } from '@/object-record/record-field/validation-schemas/emailSchema'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useLingui } from '@lingui/react/macro'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useContext, useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { MultiItemFieldInput } from './MultiItemFieldInput'; -type EmailsFieldInputProps = { - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; -}; - -export const EmailsFieldInput = ({ - onCancel, - onClickOutside, -}: EmailsFieldInputProps) => { - const { persistEmailsField, fieldValue } = useEmailsField(); +export const EmailsFieldInput = () => { + const { setDraftValue, draftValue } = useEmailsField(); const { copyToClipboard } = useCopyToClipboard(); const { t } = useLingui(); + const { onEscape, onClickOutside } = useContext(FieldInputEventContext); + const emails = useMemo( () => [ - fieldValue?.primaryEmail ? fieldValue?.primaryEmail : null, - ...(fieldValue?.additionalEmails ?? []), + draftValue?.primaryEmail ? draftValue?.primaryEmail : null, + ...(draftValue?.additionalEmails ?? []), ].filter(isDefined), - [fieldValue?.primaryEmail, fieldValue?.additionalEmails], + [draftValue?.primaryEmail, draftValue?.additionalEmails], ); - const handlePersistEmails = (updatedEmails: string[]) => { + const handleChange = (updatedEmails: string[]) => { const [nextPrimaryEmail, ...nextAdditionalEmails] = updatedEmails; - persistEmailsField({ + + const nextValue = { primaryEmail: nextPrimaryEmail ?? '', additionalEmails: nextAdditionalEmails, - }); + }; + + const parseResponse = emailsSchema.safeParse(nextValue); + + if (parseResponse.success) { + setDraftValue(parseResponse.data); + } }; const validateInput = useCallback( @@ -64,15 +67,23 @@ export const EmailsFieldInput = ({ copyToClipboard(email, t`Email copied to clipboard`); }; + const handleClickOutside = ( + _newValue: any, + event: MouseEvent | TouchEvent, + ) => { + onClickOutside?.({ newValue: draftValue, event }); + }; + + const handleEscape = (_newValue: any) => { + onEscape?.({ newValue: draftValue }); + }; + return ( { - onClickOutside?.(event); - persist(); - }} + onChange={handleChange} + onEscape={handleEscape} + onClickOutside={handleClickOutside} placeholder="Email" fieldMetadataType={FieldMetadataType.EMAILS} validateInput={validateInput} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx index ef9d885e435..4fe915c38c3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx @@ -6,29 +6,17 @@ import { FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGER import { LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/LastNamePlaceholder'; import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; -type FullNameFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; +export const FullNameFieldInput = () => { + const { draftValue, setDraftValue } = useFullNameField(); -export const FullNameFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: FullNameFieldInputProps) => { - const { draftValue, setDraftValue, persistFullNameField } = - useFullNameField(); + const { onEnter, onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); const convertToFullName = (newDoubleText: FieldDoubleText) => { return { @@ -46,29 +34,26 @@ export const FullNameFieldInput = ({ }; const handleEnter = (newDoubleText: FieldDoubleText) => { - onEnter?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onEnter?.({ newValue: convertToFullName(newDoubleText) }); }; const handleEscape = (newDoubleText: FieldDoubleText) => { - onEscape?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onEscape?.({ newValue: convertToFullName(newDoubleText) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newDoubleText: FieldDoubleText, ) => { - onClickOutside?.( - () => persistFullNameField(convertToFullName(newDoubleText)), - event, - ); + onClickOutside?.({ newValue: convertToFullName(newDoubleText), event }); }; const handleTab = (newDoubleText: FieldDoubleText) => { - onTab?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onTab?.({ newValue: convertToFullName(newDoubleText) }); }; const handleShiftTab = (newDoubleText: FieldDoubleText) => { - onShiftTab?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onShiftTab?.({ newValue: convertToFullName(newDoubleText) }); }; const handleChange = (newDoubleText: FieldDoubleText) => { 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 caca0aca63b..187f1a7a5bf 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 @@ -1,40 +1,42 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem'; import { getFieldLinkDefinedLinks } from '@/object-record/record-field/meta-types/input/utils/getFieldLinkDefinedLinks'; import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/record-field/states/recordFieldInputIsFieldInErrorComponentState'; +import { linksSchema } from '@/object-record/record-field/types/guards/isFieldLinksValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { absoluteUrlSchema } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { MultiItemFieldInput } from './MultiItemFieldInput'; -type LinksFieldInputProps = { - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; -}; +export const LinksFieldInput = () => { + const { draftValue, fieldDefinition, setDraftValue } = useLinksField(); -export const LinksFieldInput = ({ - onCancel, - onClickOutside, -}: LinksFieldInputProps) => { - const { persistLinksField, fieldValue, fieldDefinition } = useLinksField(); + const { onEscape, onClickOutside } = useContext(FieldInputEventContext); const links = useMemo<{ url: string; label: string | null }[]>( - () => getFieldLinkDefinedLinks(fieldValue), - [fieldValue], + () => getFieldLinkDefinedLinks(draftValue), + [draftValue], ); - const handlePersistLinks = ( + const handleChange = ( updatedLinks: { url: string | null; label: string | null }[], ) => { const nextPrimaryLink = updatedLinks.at(0); const nextSecondaryLinks = updatedLinks.slice(1); - persistLinksField({ + const nextValue = { primaryLinkUrl: nextPrimaryLink?.url ?? null, primaryLinkLabel: nextPrimaryLink?.label ?? null, secondaryLinks: nextSecondaryLinks, - }); + }; + + const parseResponse = linksSchema.safeParse(nextValue); + + if (parseResponse.success) { + setDraftValue(parseResponse.data); + } }; const getShowPrimaryIcon = (index: number) => index === 0 && links.length > 1; @@ -48,15 +50,23 @@ export const LinksFieldInput = ({ setIsFieldInError(hasError && values.length === 0); }; + const handleClickOutside = ( + _newValue: any, + event: MouseEvent | TouchEvent, + ) => { + onClickOutside?.({ newValue: draftValue, event }); + }; + + const handleEscape = (_newValue: any) => { + onEscape?.({ newValue: draftValue }); + }; + return ( { - onClickOutside?.(event); - persist(); - }} + onChange={handleChange} + onEscape={handleEscape} + onClickOutside={handleClickOutside} placeholder="URL" fieldMetadataType={FieldMetadataType.LINKS} validateInput={(input) => ({ 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 ddcffe846a8..9ac5bebfddf 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 @@ -7,7 +7,6 @@ import { type MultiItemBaseInputProps, } from '@/object-record/record-field/meta-types/input/components/MultiItemBaseInput'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { type PhoneRecord } from '@/object-record/record-field/types/FieldMetadata'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -25,8 +24,8 @@ import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmp type MultiItemFieldInputProps = { items: T[]; - onPersist: (updatedItems: T[]) => void; - onCancel?: () => void; + onChange: (newItemsValue: T[]) => void; + onEscape?: (newItemsValue: T[]) => void; placeholder: string; validateInput?: (input: string) => { isValid: boolean; errorMessage: string }; formatInput?: (input: string) => T; @@ -40,7 +39,7 @@ type MultiItemFieldInputProps = { newItemLabel?: string; fieldMetadataType: FieldMetadataType; renderInput?: MultiItemBaseInputProps['renderInput']; - onClickOutside?: FieldInputClickOutsideEvent; + onClickOutside?: (newItemsValue: T[], event: MouseEvent | TouchEvent) => void; onError?: (hasError: boolean, values: any[]) => void; }; @@ -48,8 +47,8 @@ type MultiItemFieldInputProps = { // This should be refactored with a hook instead that exposes those events in a context around this component and its children. export const MultiItemFieldInput = ({ items, - onPersist, - onCancel, + onChange, + onEscape, placeholder, validateInput, formatInput, @@ -61,8 +60,9 @@ export const MultiItemFieldInput = ({ onError, }: MultiItemFieldInputProps) => { const containerRef = useRef(null); - const handleDropdownClose = () => { - onCancel?.(); + + const handleEscape = () => { + onEscape?.(items); }; const instanceId = useAvailableComponentInstanceIdOrThrow( @@ -78,7 +78,8 @@ export const MultiItemFieldInput = ({ if (isEditing && isPrimaryItem) { handleSubmitInput(); } - onClickOutside?.(() => {}, event); + + onClickOutside?.(items, event); }, listenerId: instanceId, }); @@ -86,8 +87,8 @@ export const MultiItemFieldInput = ({ useHotkeysOnFocusedElement({ focusId: instanceId, keys: [Key.Escape], - callback: handleDropdownClose, - dependencies: [handleDropdownClose], + callback: handleEscape, + dependencies: [handleEscape], }); const [isInputDisplayed, setIsInputDisplayed] = useState(false); @@ -99,13 +100,15 @@ export const MultiItemFieldInput = ({ }); const isAddingNewItem = itemToEditIndex === -1; - const handleOnChange = (value: string) => { + const handleInputChange = (value: string) => { setInputValue(value); + if (!validateInput) return; setErrorData( errorData.isValid ? errorData : { isValid: true, errorMessage: '' }, ); + onError?.(false, items); }; @@ -178,19 +181,19 @@ export const MultiItemFieldInput = ({ ? [...items, newItem] : toSpliced(items, itemToEditIndex, 1, newItem); - onPersist(updatedItems); + onChange(updatedItems); setIsInputDisplayed(false); setInputValue(''); }; const handleSetPrimaryItem = (index: number) => { const updatedItems = moveArrayItem(items, { fromIndex: index, toIndex: 0 }); - onPersist(updatedItems); + onChange(updatedItems); }; const handleDeleteItem = (index: number) => { const updatedItems = toSpliced(items, index, 1); - onPersist(updatedItems); + onChange(updatedItems); }; return ( @@ -219,11 +222,11 @@ export const MultiItemFieldInput = ({ value={inputValue} hasError={!errorData.isValid} renderInput={renderInput} - onEscape={handleDropdownClose} + onEscape={handleEscape} onChange={(value) => { value - ? handleOnChange(turnIntoEmptyStringIfWhitespacesOnly(value)) - : handleOnChange(''); + ? handleInputChange(turnIntoEmptyStringIfWhitespacesOnly(value)) + : handleInputChange(''); }} onEnter={handleSubmitInput} hasItem={!!items.length} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx index 507bffe11d1..b84884d8302 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx @@ -1,22 +1,30 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField'; import { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-field/meta-types/input/constants/SelectFieldInputSelectableListComponentInstanceId'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; +import { type FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata'; + import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; -type MultiSelectFieldInputProps = { - onCancel?: () => void; -}; +export const MultiSelectFieldInput = () => { + const { fieldDefinition, draftValue, setDraftValue } = useMultiSelectField(); -export const MultiSelectFieldInput = ({ - onCancel, -}: MultiSelectFieldInputProps) => { - const { persistField, fieldDefinition, fieldValues } = useMultiSelectField(); + const { onSubmit } = useContext(FieldInputEventContext); + + const handleOptionSelected = (newDraftValue: FieldMultiSelectValue) => { + setDraftValue(newDraftValue); + }; const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); + const handleCancel = () => { + onSubmit?.({ newValue: draftValue }); + }; + return ( ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/NumberFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/NumberFieldInput.tsx index f74a8f37874..0dcaf136319 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/NumberFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/NumberFieldInput.tsx @@ -1,56 +1,97 @@ import { TextInput } from '@/ui/field/input/components/TextInput'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { FieldInputContainer } from '@/ui/field/input/components/FieldInputContainer'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { isNull } from '@sniptt/guards'; +import { useContext } from 'react'; +import { + canBeCastAsNumberOrNull, + castAsNumberOrNull, +} from '~/utils/cast-as-number-or-null'; import { useNumberField } from '../../hooks/useNumberField'; -export type FieldInputEvent = (persist: () => void) => void; +export const NumberFieldInput = () => { + const { fieldDefinition, draftValue, setDraftValue } = useNumberField(); -export type NumberFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; - -export const NumberFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: NumberFieldInputProps) => { - const { fieldDefinition, draftValue, setDraftValue, persistNumberField } = - useNumberField(); + const { onEnter, onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); + const getNumberValueToPersist = ( + newValue: string, + ): { success: boolean; value?: any } => { + if (fieldDefinition?.metadata?.settings?.type === 'percentage') { + const newValueEscaped = newValue.replaceAll('%', ''); + + if (!canBeCastAsNumberOrNull(newValueEscaped)) { + return { success: false }; + } + + const castedValue = castAsNumberOrNull(newValue); + + if (!isNull(castedValue)) { + return { success: true, value: castedValue / 100 }; + } + + return { success: true, value: null }; + } + + if (!canBeCastAsNumberOrNull(newValue)) { + return { success: false }; + } + + const castedValue = castAsNumberOrNull(newValue); + + return { success: true, value: castedValue }; + }; + const handleEnter = (newText: string) => { - onEnter?.(() => persistNumberField(newText)); + const { success, value } = getNumberValueToPersist(newText); + + const shouldNotPersist = !success; + + onEnter?.({ newValue: value, skipPersist: shouldNotPersist }); }; const handleEscape = (newText: string) => { - onEscape?.(() => persistNumberField(newText)); + const { success, value } = getNumberValueToPersist(newText); + + const shouldNotPersist = !success; + + onEscape?.({ newValue: value, skipPersist: shouldNotPersist }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newText: string, ) => { - onClickOutside?.(() => persistNumberField(newText), event); + const { success, value } = getNumberValueToPersist(newText); + + const shouldNotPersist = !success; + + onClickOutside?.({ newValue: value, skipPersist: shouldNotPersist, event }); }; const handleTab = (newText: string) => { - onTab?.(() => persistNumberField(newText)); + const { success, value } = getNumberValueToPersist(newText); + + const shouldNotPersist = !success; + + onTab?.({ newValue: value, skipPersist: shouldNotPersist }); }; const handleShiftTab = (newText: string) => { - onShiftTab?.(() => persistNumberField(newText)); + const { success, value } = getNumberValueToPersist(newText); + + const shouldNotPersist = !success; + + onShiftTab?.({ newValue: value, skipPersist: shouldNotPersist }); }; const handleChange = (newText: string) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx index 7c6e0dce53b..e5b669b6ebd 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx @@ -4,16 +4,18 @@ import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/re import { phoneSchema } from '@/object-record/record-field/validation-schemas/phoneSchema'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import styled from '@emotion/styled'; -import { type E164Number, parsePhoneNumber } from 'libphonenumber-js'; +import { parsePhoneNumber, type E164Number } from 'libphonenumber-js'; import ReactPhoneNumberInput from 'react-phone-number-input'; import 'react-phone-number-input/style.css'; import { MultiItemFieldInput } from './MultiItemFieldInput'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { createPhonesFromFieldValue } from '@/object-record/record-field/meta-types/input/utils/phonesUtils'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; +import { phonesSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue'; import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton'; import { css } from '@emotion/react'; +import { useContext } from 'react'; import { TEXT_INPUT_STYLE } from 'twenty-ui/theme'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString'; @@ -69,24 +71,18 @@ const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)` width: calc(100% - ${({ theme }) => theme.spacing(8)}); `; -type PhonesFieldInputProps = { - onCancel?: () => void; - onClickOutside?: FieldInputClickOutsideEvent; -}; +export const PhonesFieldInput = () => { + const { fieldDefinition, setDraftValue, draftValue } = usePhonesField(); -export const PhonesFieldInput = ({ - onCancel, - onClickOutside, -}: PhonesFieldInputProps) => { - const { persistPhonesField, fieldValue, fieldDefinition } = usePhonesField(); + const { onEscape, onClickOutside } = useContext(FieldInputEventContext); - const phones = createPhonesFromFieldValue(fieldValue); + const phones = createPhonesFromFieldValue(draftValue); const defaultCountry = stripSimpleQuotesFromString( fieldDefinition?.defaultValue?.primaryPhoneCountryCode, ); - const handlePersistPhones = ( + const handlePhonesChange = ( updatedPhones: { number: string; countryCode: string; @@ -94,12 +90,19 @@ export const PhonesFieldInput = ({ }[], ) => { const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones; - persistPhonesField({ + + const newValue = { primaryPhoneNumber: nextPrimaryPhone?.number ?? '', primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '', primaryPhoneCallingCode: nextPrimaryPhone?.callingCode ?? '', additionalPhones: nextAdditionalPhones, - }); + }; + + const newValidatedPhoneResponse = phonesSchema.safeParse(newValue); + + if (newValidatedPhoneResponse.success) { + setDraftValue(newValidatedPhoneResponse.data); + } }; const validateInput = (input: string) => ({ @@ -119,12 +122,23 @@ export const PhonesFieldInput = ({ setIsFieldInError(hasError && values.length === 0); }; + const handleClickOutside = ( + _newValue: any, + event: MouseEvent | TouchEvent, + ) => { + onClickOutside?.({ newValue: draftValue, event }); + }; + + const handleEscape = (_newValue: any) => { + onEscape?.({ newValue: draftValue }); + }; + return ( { +export const RatingFieldInput = () => { const { rating } = useRatingField(); - const persistField = usePersistField(); + const { isRecordFieldReadOnly } = useContext(FieldContext); + + const { onSubmit } = useContext(FieldInputEventContext); const handleChange = (newRating: FieldRatingValue) => { - onSubmit?.(() => persistField(newRating)); + onSubmit?.({ newValue: newRating }); }; return ( - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx index 1d098c0b8b8..34cd50c08b1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx @@ -1,16 +1,14 @@ import styled from '@emotion/styled'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { isWorkflowRunJsonField } from '@/object-record/record-field/meta-types/utils/isWorkflowRunJsonField'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useLingui } from '@lingui/react/macro'; -import { useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { Key } from 'ts-key-enum'; import { IconPencil } from 'twenty-ui/display'; import { CodeEditor, FloatingIconButton } from 'twenty-ui/input'; @@ -18,14 +16,6 @@ import { JsonTree, isTwoFirstDepths } from 'twenty-ui/json-visualizer'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; import { useJsonField } from '../../hooks/useJsonField'; -type RawJsonFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; - const CONTAINER_HEIGHT = 300; const StyledContainer = styled.div` @@ -51,47 +41,66 @@ const StyledJsonTreeContainer = styled.div` width: min-content; `; -export const RawJsonFieldInput = ({ - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: RawJsonFieldInputProps) => { +export const RawJsonFieldInput = () => { const { t } = useLingui(); const { copyToClipboard } = useCopyToClipboard(); - const { - draftValue, - precomputedDraftValue, - setDraftValue, - persistJsonField, - fieldDefinition, - } = useJsonField(); + const { draftValue, precomputedDraftValue, setDraftValue, fieldDefinition } = + useJsonField(); const containerRef = useRef(null); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); + const { onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); + const [isEditing, setIsEditing] = useState(false); + const parseJsonField = ( + nextValue: string, + ): { success: boolean; value?: any } => { + if (!nextValue) { + return { + success: true, + value: null, + }; + } + + try { + return { success: true, value: JSON.parse(nextValue) }; + } catch { + return { success: false }; + } + }; + const handleEscape = (newText: string) => { - onEscape?.(() => persistJsonField(newText)); + const { success, value } = parseJsonField(newText); + + onEscape?.({ newValue: value, skipPersist: !success }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newText: string, ) => { - onClickOutside?.(() => persistJsonField(newText), event); + const { success, value } = parseJsonField(newText); + + onClickOutside?.({ newValue: value, skipPersist: !success, event }); }; const handleTab = (newText: string) => { - onTab?.(() => persistJsonField(newText)); + const { success, value } = parseJsonField(newText); + + onTab?.({ newValue: value, skipPersist: !success }); }; const handleShiftTab = (newText: string) => { - onShiftTab?.(() => persistJsonField(newText)); + const { success, value } = parseJsonField(newText); + + onShiftTab?.({ newValue: value, skipPersist: !success }); }; const handleChange = (newText: string) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx index 7bfa67456f4..7d2e449a0be 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx @@ -7,13 +7,13 @@ import { type TaskTarget } from '@/activities/types/TaskTarget'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useRelationField } from '@/object-record/record-field/meta-types/hooks/useRelationField'; import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer'; import { useUpdateRelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/hooks/useUpdateRelationFromManyFieldInput'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState'; import { type FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; -import { type FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { type FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker'; import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch'; @@ -24,18 +24,14 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -type RelationFromManyFieldInputProps = { - onSubmit?: FieldInputEvent; -}; - -export const RelationFromManyFieldInput = ({ - onSubmit, -}: RelationFromManyFieldInputProps) => { +export const RelationFromManyFieldInput = () => { const { fieldDefinition, recordId } = useContext(FieldContext); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); + const { onSubmit } = useContext(FieldInputEventContext); + const { updateRelation } = useUpdateRelationFromManyFieldInput(); const fieldName = fieldDefinition.metadata.fieldName; const objectMetadataNameSingular = @@ -51,7 +47,7 @@ export const RelationFromManyFieldInput = ({ const { fieldValue } = useRelationField(); const handleSubmit = () => { - onSubmit?.(() => {}); + onSubmit?.({ skipPersist: true }); }; const isRelationFromActivityTargets = diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx index 3640b36a7ca..afe325391f9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationToOneFieldInput.tsx @@ -1,4 +1,4 @@ -import { usePersistField } from '../../../hooks/usePersistField'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useRelationField } from '../../hooks/useRelationField'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -6,7 +6,6 @@ import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionComponentState'; import { recordFieldInputLayoutDirectionLoadingComponentState } from '@/object-record/record-field/states/recordFieldInputLayoutDirectionLoadingComponentState'; -import { type FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker'; import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState'; import { type SingleRecordPickerRecord } from '@/object-record/record-picker/single-record-picker/types/SingleRecordPickerRecord'; @@ -14,21 +13,14 @@ import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useContext } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { IconForbid } from 'twenty-ui/display'; -export type RelationToOneFieldInputProps = { - onSubmit?: FieldInputEvent; - onCancel?: () => void; -}; - -export const RelationToOneFieldInput = ({ - onSubmit, - onCancel, -}: RelationToOneFieldInputProps) => { +export const RelationToOneFieldInput = () => { const { fieldDefinition, recordId } = useRelationField(); - const persistField = usePersistField(); + const { onSubmit, onCancel } = useContext(FieldInputEventContext); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, @@ -36,7 +28,7 @@ export const RelationToOneFieldInput = ({ const handleRecordSelected = ( selectedRecord: SingleRecordPickerRecord | null | undefined, - ) => onSubmit?.(() => persistField(selectedRecord?.record ?? null)); + ) => onSubmit?.({ newValue: selectedRecord?.record ?? null }); const { objectMetadataItem: relationObjectMetadataItem } = useObjectMetadataItem({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx index 7a551918c3e..5ad72153e3b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx @@ -1,17 +1,17 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; -import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { useRichTextCommandMenu } from '@/command-menu/hooks/useRichTextCommandMenu'; import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; + +import { type FieldRichTextV2Metadata } from '@/object-record/record-field/types/FieldMetadata'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { Suspense, lazy, useRef } from 'react'; +import { Suspense, lazy, useContext, useRef } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { IconLayoutSidebarLeftCollapse } from 'twenty-ui/display'; import { FloatingIconButton } from 'twenty-ui/input'; @@ -22,13 +22,6 @@ const ActivityRichTextEditor = lazy(() => })), ); -export type RichTextFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onCancel?: () => void; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; -}; - const StyledContainer = styled.div` background-color: ${({ theme }) => theme.background.primary}; width: 480px; @@ -60,29 +53,34 @@ const LoadingSkeleton = () => { ); }; -export const RichTextFieldInput = ({ - targetableObject, - onClickOutside, - onEscape, -}: { - targetableObject: Pick & { - targetObjectNameSingular: +export const RichTextFieldInput = () => { + const { fieldDefinition, recordId } = useContext(FieldContext); + + const targetableObject = { + id: recordId, + targetObjectNameSingular: ( + fieldDefinition as { + metadata: FieldRichTextV2Metadata; + } + ).metadata.objectMetadataNameSingular as | CoreObjectNameSingular.Note - | CoreObjectNameSingular.Task; + | CoreObjectNameSingular.Task, }; -} & RichTextFieldInputProps) => { + const { editRichText } = useRichTextCommandMenu(); const containerRef = useRef(null); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); + const { onEscape, onClickOutside } = useContext(FieldInputEventContext); + const handleClickOutside = (event: MouseEvent | TouchEvent) => { - onClickOutside?.(() => {}, event); + onClickOutside?.({ event, skipPersist: true }); }; const handleEscape = () => { - onEscape?.(() => {}); + onEscape?.({ skipPersist: true }); }; useRegisterInputEvents({ @@ -106,7 +104,7 @@ export const RichTextFieldInput = ({ Icon={IconLayoutSidebarLeftCollapse} size="small" onClick={() => { - onEscape?.(() => {}); + onEscape?.({ skipPersist: true }); editRichText( targetableObject.id, targetableObject.targetObjectNameSingular, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx index 20b73022cb1..2a1069306ea 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx @@ -1,27 +1,21 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useClearField } from '@/object-record/record-field/hooks/useClearField'; import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField'; import { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-field/meta-types/input/constants/SelectFieldInputSelectableListComponentInstanceId'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { SelectInput } from '@/ui/field/input/components/SelectInput'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { useState } from 'react'; +import { useContext, useState } from 'react'; import { Key } from 'ts-key-enum'; import { isDefined } from 'twenty-shared/utils'; import { type SelectOption } from 'twenty-ui/input'; -type SelectFieldInputProps = { - onSubmit?: FieldInputEvent; - onCancel?: () => void; -}; +export const SelectFieldInput = () => { + const { fieldDefinition, fieldValue } = useSelectField(); -export const SelectFieldInput = ({ - onSubmit, - onCancel, -}: SelectFieldInputProps) => { - const { persistField, fieldDefinition, fieldValue } = useSelectField(); + const { onCancel, onSubmit } = useContext(FieldInputEventContext); const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, @@ -44,7 +38,7 @@ export const SelectFieldInput = ({ }; const handleSubmit = (option: SelectOption) => { - onSubmit?.(() => persistField(option?.value)); + onSubmit?.({ newValue: option.value }); resetSelectedItem(); }; @@ -76,8 +70,7 @@ export const SelectFieldInput = ({ (option) => option.value === itemId, ); if (isDefined(option)) { - onSubmit?.(() => persistField(option.value)); - resetSelectedItem(); + handleSubmit(option); } }} onOptionSelected={handleSubmit} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx index b96a9b5fca1..2fc2c395a2f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx @@ -1,60 +1,50 @@ import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput'; -import { usePersistField } from '../../../hooks/usePersistField'; import { useTextField } from '../../hooks/useTextField'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + import { FieldInputContainer } from '@/ui/field/input/components/FieldInputContainer'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useContext } from 'react'; import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly'; -export type TextFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; -}; - -export const TextFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, -}: TextFieldInputProps) => { +export const TextFieldInput = () => { const { fieldDefinition, draftValue, setDraftValue } = useTextField(); + const { onEnter, onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); + const instanceId = useAvailableComponentInstanceIdOrThrow( RecordFieldComponentInstanceContext, ); - const persistField = usePersistField(); const handleEnter = (newText: string) => { - onEnter?.(() => persistField(newText.trim())); + onEnter?.({ newValue: newText.trim() }); }; const handleEscape = (newText: string) => { - onEscape?.(() => persistField(newText.trim())); + onEscape?.({ newValue: newText.trim() }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newText: string, ) => { - onClickOutside?.(() => persistField(newText.trim()), event); + onClickOutside?.({ + newValue: newText.trim(), + event, + }); }; const handleTab = (newText: string) => { - onTab?.(() => persistField(newText.trim())); + onTab?.({ newValue: newText.trim() }); }; const handleShiftTab = (newText: string) => { - onShiftTab?.(() => persistField(newText.trim())); + onShiftTab?.({ newValue: newText.trim() }); }; const handleChange = (newText: string) => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/ArrayFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/ArrayFieldInput.stories.tsx index 35097b331c8..c116ae6a2e1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/ArrayFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/ArrayFieldInput.stories.tsx @@ -1,9 +1,10 @@ -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { type Meta, type StoryObj } from '@storybook/react'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect } from 'react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { useArrayField } from '@/object-record/record-field/meta-types/hooks/useArrayField'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -12,48 +13,34 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { FieldMetadataType } from '~/generated-metadata/graphql'; import { ArrayFieldInput } from '../ArrayFieldInput'; -const updateRecord = fn(); +const { FieldInputEventContextProviderWithJestMocks } = + getFieldInputEventContextProviderWithJestMocks(); const ArrayValueSetterEffect = ({ value }: { value: string[] }) => { - const { setFieldValue } = useArrayField(); + const { setFieldValue, setDraftValue } = useArrayField(); useEffect(() => { setFieldValue(value); - }, [setFieldValue, value]); + setDraftValue(value); + }, [setFieldValue, value, setDraftValue]); return null; }; -type ArrayFieldValueGaterProps = Pick< - ArrayInputWithContextProps, - 'onCancel' | 'onClickOutside' ->; - -const ArrayFieldValueGater = ({ - onCancel, - onClickOutside, -}: ArrayFieldValueGaterProps) => { +const ArrayFieldValueGater = () => { const { fieldValue } = useArrayField(); - return ( - fieldValue && ( - - ) - ); + return fieldValue && ; }; type ArrayInputWithContextProps = { value: string[]; recordId?: string; - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; }; const ArrayInputWithContext = ({ value, recordId = 'record-id', - onCancel, - onClickOutside, }: ArrayInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); @@ -99,14 +86,12 @@ const ArrayInputWithContext = ({ recordId, isLabelIdentifier: false, isRecordFieldReadOnly: false, - useUpdateRecord: () => [updateRecord, { loading: false }], }} > - - + + + + ); @@ -155,21 +140,5 @@ export const TrimInput: Story = { const tag2Elements = canvas.queryAllByText('tag2'); expect(tag2Elements).toHaveLength(2); }); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: 'record-id' }, - updateOneRecordInput: { - tags: [ - 'tag1', - 'tag2', - 'tag2', // The second tag2 is not trimmed, so it remains as a duplicate - ], - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx index fe9948b1f52..6d937442b17 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx @@ -1,5 +1,5 @@ import { type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, within } from '@storybook/test'; +import { expect, userEvent, within } from '@storybook/test'; import { useEffect } from 'react'; import { useSetRecoilState } from 'recoil'; @@ -7,13 +7,14 @@ import { recordStoreFamilyState } from '@/object-record/record-store/states/reco import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; -import { - BooleanFieldInput, - type BooleanFieldInputProps, -} from '../BooleanFieldInput'; +import { BooleanFieldInput } from '../BooleanFieldInput'; + +const { FieldInputEventContextProviderWithJestMocks, handleSubmitMocked } = + getFieldInputEventContextProviderWithJestMocks(); const BooleanFieldValueSetterEffect = ({ value, @@ -31,7 +32,7 @@ const BooleanFieldValueSetterEffect = ({ return <>; }; -type BooleanFieldInputWithContextProps = BooleanFieldInputProps & { +type BooleanFieldInputWithContextProps = { value: boolean; recordId?: string; }; @@ -39,7 +40,6 @@ type BooleanFieldInputWithContextProps = BooleanFieldInputProps & { const BooleanFieldInputWithContext = ({ value, recordId, - onSubmit, }: BooleanFieldInputWithContextProps) => { return ( - - + + + + ); @@ -92,19 +94,9 @@ export default meta; type Story = StoryObj; -const submitJestFn = fn(); - export const Default: Story = {}; export const Toggle: Story = { - args: { - onSubmit: submitJestFn, - }, - argTypes: { - onSubmit: { - control: false, - }, - }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -114,18 +106,18 @@ export const Toggle: Story = { await expect(trueText).toBeInTheDocument(); - await expect(submitJestFn).toHaveBeenCalledTimes(0); + await expect(handleSubmitMocked).toHaveBeenCalledTimes(0); await userEvent.click(input); await expect(input).toHaveTextContent('False'); - await expect(submitJestFn).toHaveBeenCalledTimes(1); + await expect(handleSubmitMocked).toHaveBeenCalledTimes(1); await userEvent.click(input); await expect(input).toHaveTextContent('True'); - await expect(submitJestFn).toHaveBeenCalledTimes(2); + await expect(handleSubmitMocked).toHaveBeenCalledTimes(2); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx index ab71fde9a4b..cebbcfb0b51 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx @@ -1,21 +1,27 @@ import { type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, within } from '@storybook/test'; +import { expect, userEvent, within } from '@storybook/test'; import { useEffect } from 'react'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { StorybookFieldInputDropdownFocusIdSetterEffect } from '~/testing/components/StorybookFieldInputDropdownFocusIdSetterEffect'; import { useDateTimeField } from '../../../hooks/useDateTimeField'; -import { - DateTimeFieldInput, - type DateTimeFieldInputProps, -} from '../DateTimeFieldInput'; +import { DateTimeFieldInput } from '../DateTimeFieldInput'; + +const { + FieldInputEventContextProviderWithJestMocks, + handleEscapeMocked, + handleClickoutsideMocked, + handleEnterMocked, +} = getFieldInputEventContextProviderWithJestMocks(); + const formattedDate = new Date(2022, 0, 1, 2, 0, 0); const DateFieldValueSetterEffect = ({ value }: { value: Date }) => { @@ -28,30 +34,13 @@ const DateFieldValueSetterEffect = ({ value }: { value: Date }) => { return <>; }; -type DateFieldValueGaterProps = Pick< - DateTimeFieldInputProps, - 'onEscape' | 'onEnter' | 'onClickOutside' ->; - -const DateFieldValueGater = ({ - onEscape, - onEnter, - onClickOutside, -}: DateFieldValueGaterProps) => { +const DateFieldValueGater = () => { const { fieldValue } = useDateTimeField(); - return ( - fieldValue && ( - - ) - ); + return fieldValue && ; }; -type DateFieldInputWithContextProps = DateTimeFieldInputProps & { +type DateFieldInputWithContextProps = { value: Date; recordId: string; }; @@ -59,9 +48,6 @@ type DateFieldInputWithContextProps = DateTimeFieldInputProps & { const DateFieldInputWithContext = ({ value, recordId, - onEscape, - onEnter, - onClickOutside, }: DateFieldInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const instanceId = getRecordFieldInputInstanceId({ @@ -104,31 +90,25 @@ const DateFieldInputWithContext = ({ isRecordFieldReadOnly: false, }} > - - - + + + + +
); }; -const escapeJestFn = fn(); -const enterJestFn = fn(); -const clickOutsideJestFn = fn(); - const meta: Meta = { title: 'UI/Data/Field/Input/DateTimeFieldInput', component: DateFieldInputWithContext, args: { value: formattedDate, - onEscape: escapeJestFn, - onEnter: enterJestFn, - onClickOutside: clickOutsideJestFn, + onEscape: handleEscapeMocked, + onEnter: handleEnterMocked, + onClickOutside: handleClickoutsideMocked, }, argTypes: { onEscape: { @@ -160,36 +140,36 @@ export const ClickOutside: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + await expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); await canvas.findByText('January'); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); await userEvent.click(emptyDiv); - await expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); + await expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1); }, }; export const Escape: Story = { play: async ({ canvasElement }) => { - await expect(escapeJestFn).toHaveBeenCalledTimes(0); + await expect(handleEscapeMocked).toHaveBeenCalledTimes(0); const canvas = within(canvasElement); await canvas.findByText('January'); await userEvent.keyboard('{escape}'); - await expect(escapeJestFn).toHaveBeenCalledTimes(1); + await expect(handleEscapeMocked).toHaveBeenCalledTimes(1); }, }; export const Enter: Story = { play: async ({ canvasElement }) => { - await expect(enterJestFn).toHaveBeenCalledTimes(0); + await expect(handleEnterMocked).toHaveBeenCalledTimes(0); const canvas = within(canvasElement); await canvas.findByText('January'); await userEvent.keyboard('{enter}'); - await expect(enterJestFn).toHaveBeenCalledTimes(1); + await expect(handleEnterMocked).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailsFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailsFieldInput.stories.tsx index 9621a35cd9c..151279b8f6b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailsFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailsFieldInput.stories.tsx @@ -1,10 +1,11 @@ -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { type Meta, type StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; import { useEffect } from 'react'; import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { type FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; @@ -18,46 +19,34 @@ import { EmailsFieldInput } from '../EmailsFieldInput'; const updateRecord = fn(); +const { FieldInputEventContextProviderWithJestMocks } = + getFieldInputEventContextProviderWithJestMocks(); + const EmailValueSetterEffect = ({ value }: { value: FieldEmailsValue }) => { - const { setFieldValue } = useEmailsField(); + const { setFieldValue, setDraftValue } = useEmailsField(); useEffect(() => { setFieldValue(value); - }, [setFieldValue, value]); + setDraftValue(value); + }, [setFieldValue, value, setDraftValue]); return null; }; -type EmailFieldValueGaterProps = Pick< - EmailInputWithContextProps, - 'onCancel' | 'onClickOutside' ->; - -const EmailFieldValueGater = ({ - onCancel, - onClickOutside, -}: EmailFieldValueGaterProps) => { +const EmailFieldValueGater = () => { const { fieldValue } = useEmailsField(); - return ( - fieldValue && ( - - ) - ); + return fieldValue && ; }; type EmailInputWithContextProps = { value: FieldEmailsValue; recordId?: string; - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; }; const EmailInputWithContext = ({ value, recordId = 'record-id', - onCancel, - onClickOutside, }: EmailInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const instanceId = getRecordFieldInputInstanceId({ @@ -101,11 +90,10 @@ const EmailInputWithContext = ({ useUpdateRecord: () => [updateRecord, { loading: false }], }} > - - + + + + ); @@ -159,21 +147,6 @@ export const TrimInput: Story = { const newEmailElement = await canvas.findByText('new.email@example.com'); expect(newEmailElement).toBeVisible(); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: 'record-id' }, - updateOneRecordInput: { - emails: { - primaryEmail: 'john@example.com', - additionalEmails: ['new.email@example.com'], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx index 0a0c4b0956a..d47216791d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/LinksFieldInput.stories.tsx @@ -4,6 +4,7 @@ import { useEffect } from 'react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -15,6 +16,12 @@ import { LinksFieldInput } from '../LinksFieldInput'; const updateRecord = fn(); +const { + FieldInputEventContextProviderWithJestMocks, + handleEscapeMocked, + handleClickoutsideMocked, +} = getFieldInputEventContextProviderWithJestMocks(); + const LinksValueSetterEffect = ({ value, }: { @@ -24,11 +31,12 @@ const LinksValueSetterEffect = ({ secondaryLinks: Array<{ url: string | null; label: string | null }> | null; }; }) => { - const { setFieldValue } = useLinksField(); + const { setFieldValue, setDraftValue } = useLinksField(); useEffect(() => { setFieldValue(value); - }, [setFieldValue, value]); + setDraftValue(value); + }, [setFieldValue, value, setDraftValue]); return null; }; @@ -40,33 +48,17 @@ type LinksInputWithContextProps = { secondaryLinks: Array<{ url: string | null; label: string | null }> | null; }; recordId: string; - onCancel?: () => void; - onClickOutside?: (event: MouseEvent | TouchEvent) => void; }; -type LinksFieldValueGaterProps = Pick< - LinksInputWithContextProps, - 'onCancel' | 'onClickOutside' ->; - -const LinksFieldValueGater = ({ - onCancel, - onClickOutside, -}: LinksFieldValueGaterProps) => { +const LinksFieldValueGater = () => { const { fieldValue } = useLinksField(); - return ( - fieldValue && ( - - ) - ); + return fieldValue && ; }; const LinksInputWithContext = ({ value, recordId, - onCancel, - onClickOutside, }: LinksInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const instanceId = getRecordFieldInputInstanceId({ @@ -111,11 +103,10 @@ const LinksInputWithContext = ({ useUpdateRecord: () => [updateRecord, { loading: false }], }} > - - + + + +
@@ -127,9 +118,6 @@ const getPrimaryLinkBookmarkIcon = (canvasElement: HTMLElement) => // It would be better to use an aria-label on the icon, but we'll do this for now canvasElement.querySelector('svg[class*="tabler-icon-bookmark"]'); -const cancelJestFn = fn(); -const clickOutsideJestFn = fn(); - const meta: Meta = { title: 'UI/Data/Field/Input/LinksFieldInput', component: LinksInputWithContext, @@ -140,8 +128,6 @@ const meta: Meta = { secondaryLinks: null, }, recordId: '123', - onCancel: cancelJestFn, - onClickOutside: clickOutsideJestFn, }, argTypes: { onCancel: { control: false }, @@ -242,22 +228,6 @@ export const CreatePrimaryLink: Story = { const linkDisplay = await canvas.findByText('twenty.com'); expect(linkDisplay).toBeVisible(); - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://www.twenty.com', - primaryLinkLabel: null, - secondaryLinks: [], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); - expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument(); }, }; @@ -272,22 +242,6 @@ export const TrimInput: Story = { const linkDisplay = await canvas.findByText('twenty.com'); expect(linkDisplay).toBeVisible(); - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://www.twenty.com', - primaryLinkLabel: null, - secondaryLinks: [], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); - expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument(); }, }; @@ -316,27 +270,6 @@ export const AddSecondaryLink: Story = { const secondaryLink = await canvas.findByText('docs.twenty.com'); expect(secondaryLink).toBeVisible(); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://www.twenty.com', - primaryLinkLabel: 'Twenty Website', - secondaryLinks: [ - { - url: 'https://docs.twenty.com', - label: null, - }, - ], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; @@ -369,22 +302,6 @@ export const DeletePrimaryLink: Story = { const input = await canvas.findByPlaceholderText('URL'); expect(input).toBeVisible(); expect(input).toHaveValue(''); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: null, - primaryLinkLabel: null, - secondaryLinks: [], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; @@ -427,22 +344,6 @@ export const DeletePrimaryLinkAndUseSecondaryLinkAsTheNewPrimaryLink: Story = { expect(oldPrimaryLink).not.toBeInTheDocument(); expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument(); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://docs.twenty.com', - primaryLinkLabel: 'Documentation', - secondaryLinks: [], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; @@ -485,22 +386,6 @@ export const DeleteSecondaryLink: Story = { expect(secondaryLink).not.toBeInTheDocument(); expect(getPrimaryLinkBookmarkIcon(canvasElement)).not.toBeInTheDocument(); - - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://www.twenty.com', - primaryLinkLabel: 'Twenty Website', - secondaryLinks: [], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; @@ -511,14 +396,14 @@ export const ClickOutside: Story = { const input = await canvas.findByPlaceholderText('URL'); await userEvent.click(input); - expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); const outsideDiv = await canvas.findByTestId( 'links-field-input-click-outside-div', ); await userEvent.click(outsideDiv); - expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1); }, }; @@ -526,14 +411,14 @@ export const Cancel: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(cancelJestFn).toHaveBeenCalledTimes(0); + expect(handleEscapeMocked).toHaveBeenCalledTimes(0); const input = await canvas.findByPlaceholderText('URL'); await userEvent.click(input); await userEvent.keyboard('{escape}'); - expect(cancelJestFn).toHaveBeenCalledTimes(1); + expect(handleEscapeMocked).toHaveBeenCalledTimes(1); }, }; @@ -602,28 +487,6 @@ export const MakeSecondaryLinkPrimary: Story = { getCanvasElementForDropdownTesting(), ).findByText('Set as Primary'); await userEvent.click(setPrimaryOption); - - // Documentation should now be the primary link - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: '123' }, - updateOneRecordInput: { - links: { - primaryLinkUrl: 'https://docs.twenty.com', - primaryLinkLabel: 'Documentation', - secondaryLinks: [ - { - url: 'https://www.twenty.com', - label: 'Twenty Website', - }, - ], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx index 0676affc590..510c6201dfa 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx @@ -1,5 +1,5 @@ import { type Decorator, type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect, useState } from 'react'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; @@ -7,6 +7,7 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -14,10 +15,16 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { StorybookFieldInputDropdownFocusIdSetterEffect } from '~/testing/components/StorybookFieldInputDropdownFocusIdSetterEffect'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { useNumberField } from '../../../hooks/useNumberField'; -import { - NumberFieldInput, - type NumberFieldInputProps, -} from '../NumberFieldInput'; +import { NumberFieldInput } from '../NumberFieldInput'; + +const { + FieldInputEventContextProviderWithJestMocks, + handleEnterMocked, + handleEscapeMocked, + handleClickoutsideMocked, + handleTabMocked, + handleShiftTabMocked, +} = getFieldInputEventContextProviderWithJestMocks(); const NumberFieldValueSetterEffect = ({ value }: { value: number }) => { const { setFieldValue } = useNumberField(); @@ -29,7 +36,7 @@ const NumberFieldValueSetterEffect = ({ value }: { value: number }) => { return <>; }; -type NumberFieldInputWithContextProps = NumberFieldInputProps & { +type NumberFieldInputWithContextProps = { value: number; recordId: string; }; @@ -37,11 +44,6 @@ type NumberFieldInputWithContextProps = NumberFieldInputProps & { const NumberFieldInputWithContext = ({ recordId, value, - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, }: NumberFieldInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); @@ -94,15 +96,11 @@ const NumberFieldInputWithContext = ({ isRecordFieldReadOnly: false, }} > - {isReady && } - - + + {isReady && } + + + {isReady &&
}
@@ -110,19 +108,13 @@ const NumberFieldInputWithContext = ({ ); }; -const enterJestFn = fn(); -const escapeJestfn = fn(); -const clickOutsideJestFn = fn(); -const tabJestFn = fn(); -const shiftTabJestFn = fn(); - const clearMocksDecorator: Decorator = (Story, context) => { if (context.parameters.clearMocks === true) { - enterJestFn.mockClear(); - escapeJestfn.mockClear(); - clickOutsideJestFn.mockClear(); - tabJestFn.mockClear(); - shiftTabJestFn.mockClear(); + handleEnterMocked.mockClear(); + handleEscapeMocked.mockClear(); + handleClickoutsideMocked.mockClear(); + handleTabMocked.mockClear(); + handleShiftTabMocked.mockClear(); } return ; }; @@ -133,11 +125,11 @@ const meta: Meta = { args: { value: 1000, isPositive: true, - onEnter: enterJestFn, - onEscape: escapeJestfn, - onClickOutside: clickOutsideJestFn, - onTab: tabJestFn, - onShiftTab: shiftTabJestFn, + onEnter: handleEnterMocked, + onEscape: handleEscapeMocked, + onClickOutside: handleClickoutsideMocked, + onTab: handleTabMocked, + onShiftTab: handleShiftTabMocked, }, argTypes: { onEnter: { control: false }, @@ -162,13 +154,13 @@ export const Enter: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(enterJestFn).toHaveBeenCalledTimes(0); + expect(handleEnterMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{enter}'); await waitFor(() => { - expect(enterJestFn).toHaveBeenCalledTimes(1); + expect(handleEnterMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -177,13 +169,13 @@ export const Escape: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(escapeJestfn).toHaveBeenCalledTimes(0); + expect(handleEscapeMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{esc}'); await waitFor(() => { - expect(escapeJestfn).toHaveBeenCalledTimes(1); + expect(handleEscapeMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -192,7 +184,7 @@ export const ClickOutside: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); @@ -200,7 +192,7 @@ export const ClickOutside: Story = { await userEvent.click(emptyDiv); await waitFor(() => { - expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -209,13 +201,13 @@ export const Tab: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(tabJestFn).toHaveBeenCalledTimes(0); + expect(handleTabMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{tab}'); await waitFor(() => { - expect(tabJestFn).toHaveBeenCalledTimes(1); + expect(handleTabMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -224,13 +216,13 @@ export const ShiftTab: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(shiftTabJestFn).toHaveBeenCalledTimes(0); + expect(handleShiftTabMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{shift>}{tab}'); await waitFor(() => { - expect(shiftTabJestFn).toHaveBeenCalledTimes(1); + expect(handleShiftTabMocked).toHaveBeenCalledTimes(1); }); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhonesFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhonesFieldInput.stories.tsx index c902fc17e59..4cfa6834110 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhonesFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhonesFieldInput.stories.tsx @@ -1,12 +1,12 @@ -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { type Meta, type StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; import { useEffect } from 'react'; import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { usePhonesField } from '@/object-record/record-field/meta-types/hooks/usePhonesField'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; -import { type FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { type FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -15,48 +15,34 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { FieldMetadataType } from '~/generated-metadata/graphql'; import { PhonesFieldInput } from '../PhonesFieldInput'; -const updateRecord = fn(); +const { FieldInputEventContextProviderWithJestMocks } = + getFieldInputEventContextProviderWithJestMocks(); const PhoneValueSetterEffect = ({ value }: { value: FieldPhonesValue }) => { - const { setFieldValue } = usePhonesField(); + const { setFieldValue, setDraftValue } = usePhonesField(); useEffect(() => { setFieldValue(value); - }, [setFieldValue, value]); + setDraftValue(value); + }, [setFieldValue, value, setDraftValue]); return null; }; -type PhoneFieldValueGaterProps = Pick< - PhoneInputWithContextProps, - 'onCancel' | 'onClickOutside' ->; - -const PhoneFieldValueGater = ({ - onCancel, - onClickOutside, -}: PhoneFieldValueGaterProps) => { +const PhoneFieldValueGater = () => { const { fieldValue } = usePhonesField(); - return ( - fieldValue && ( - - ) - ); + return fieldValue && ; }; type PhoneInputWithContextProps = { value: FieldPhonesValue; recordId?: string; - onCancel?: () => void; - onClickOutside?: FieldInputClickOutsideEvent; }; const PhoneInputWithContext = ({ value, recordId = 'record-id', - onCancel, - onClickOutside, }: PhoneInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const instanceId = getRecordFieldInputInstanceId({ @@ -97,14 +83,13 @@ const PhoneInputWithContext = ({ recordId, isLabelIdentifier: false, isRecordFieldReadOnly: false, - useUpdateRecord: () => [updateRecord, { loading: false }], + useUpdateRecord: () => [() => {}, { loading: false }], }} > - - + + + + ); @@ -173,35 +158,6 @@ export const TrimInput: Story = { const newPhoneElement = await canvas.findByText('+33 6 42 64 62 74'); expect(newPhoneElement).toBeVisible(); - - // Verify the update was called with swapped phones - await waitFor(() => { - expect(updateRecord).toHaveBeenCalledWith({ - variables: { - where: { id: 'record-id' }, - updateOneRecordInput: { - phones: { - primaryPhoneCallingCode: '+33', - primaryPhoneCountryCode: 'FR', - primaryPhoneNumber: '642646272', - additionalPhones: [ - { - countryCode: 'FR', - number: '642646273', - callingCode: '+33', - }, - { - countryCode: 'FR', - number: '642646274', - callingCode: '+33', - }, - ], - }, - }, - }, - }); - }); - expect(updateRecord).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx index 859416103db..5056618d69f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx @@ -1,10 +1,11 @@ import { type Decorator, type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect } from 'react'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -12,10 +13,10 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { FieldMetadataType } from 'twenty-shared/types'; import { type FieldRatingValue } from '../../../../types/FieldMetadata'; import { useRatingField } from '../../../hooks/useRatingField'; -import { - RatingFieldInput, - type RatingFieldInputProps, -} from '../RatingFieldInput'; +import { RatingFieldInput } from '../RatingFieldInput'; + +const { FieldInputEventContextProviderWithJestMocks, handleSubmitMocked } = + getFieldInputEventContextProviderWithJestMocks(); const RatingFieldValueSetterEffect = ({ value, @@ -31,7 +32,7 @@ const RatingFieldValueSetterEffect = ({ return <>; }; -type RatingFieldInputWithContextProps = RatingFieldInputProps & { +type RatingFieldInputWithContextProps = { value: FieldRatingValue; recordId: string; }; @@ -39,7 +40,6 @@ type RatingFieldInputWithContextProps = RatingFieldInputProps & { const RatingFieldInputWithContext = ({ recordId, value, - onSubmit, }: RatingFieldInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); @@ -83,17 +83,17 @@ const RatingFieldInputWithContext = ({ }} > - + + + ); }; -const submitJestFn = fn(); - const clearMocksDecorator: Decorator = (Story, context) => { if (context.parameters.clearMocks === true) { - submitJestFn.mockClear(); + handleSubmitMocked.mockClear(); } return ; }; @@ -103,7 +103,7 @@ const meta: Meta = { component: RatingFieldInputWithContext, args: { value: '3', - onSubmit: submitJestFn, + onSubmit: handleSubmitMocked, }, argTypes: { onSubmit: { control: false }, @@ -124,7 +124,7 @@ export const Submit: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(submitJestFn).toHaveBeenCalledTimes(0); + expect(handleSubmitMocked).toHaveBeenCalledTimes(0); const input = canvas.getByRole('slider', { name: 'Rating' }); const firstStar = input.firstElementChild; @@ -136,7 +136,7 @@ export const Submit: Story = { await userEvent.click(firstStar); await waitFor(() => { - expect(submitJestFn).toHaveBeenCalledTimes(1); + expect(handleSubmitMocked).toHaveBeenCalledTimes(1); }); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx index ad5a477f725..4020cd798ce 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx @@ -1,5 +1,5 @@ import { type Decorator, type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect } from 'react'; import { useSetRecoilState } from 'recoil'; @@ -22,10 +22,9 @@ import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentTyp import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { FieldMetadataType } from 'twenty-shared/types'; import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing'; -import { - RelationToOneFieldInput, - type RelationToOneFieldInputProps, -} from '../RelationToOneFieldInput'; +import { RelationToOneFieldInput } from '../RelationToOneFieldInput'; + +import { getFieldInputEventContextProviderWithJestMocks } from './utils/getFieldInputEventContextProviderWithJestMocks'; const RelationWorkspaceSetterEffect = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); @@ -50,15 +49,19 @@ const RelationWorkspaceSetterEffect = () => { return <>; }; -type RelationToOneFieldInputWithContextProps = RelationToOneFieldInputProps & { +const { + FieldInputEventContextProviderWithJestMocks, + handleSubmitMocked, + handleCancelMocked, +} = getFieldInputEventContextProviderWithJestMocks(); + +type RelationToOneFieldInputWithContextProps = { value: number; recordId: string; }; const RelationToOneFieldInputWithContext = ({ recordId, - onSubmit, - onCancel, }: RelationToOneFieldInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); @@ -101,8 +104,11 @@ const RelationToOneFieldInputWithContext = ({ instanceId: 'relation-to-one-field-input-123-Relation', }} > - - + + + + +
@@ -110,13 +116,10 @@ const RelationToOneFieldInputWithContext = ({ ); }; -const submitJestFn = fn(); -const cancelJestFn = fn(); - const clearMocksDecorator: Decorator = (Story, context) => { if (context.parameters.clearMocks === true) { - submitJestFn.mockClear(); - cancelJestFn.mockClear(); + handleSubmitMocked.mockClear(); + handleCancelMocked.mockClear(); } return ; }; @@ -126,8 +129,8 @@ const meta: Meta = { component: RelationToOneFieldInputWithContext, args: { useEditButton: true, - onSubmit: submitJestFn, - onCancel: cancelJestFn, + onSubmit: handleSubmitMocked, + onCancel: handleCancelMocked, }, argTypes: { onSubmit: { control: false }, @@ -154,7 +157,7 @@ export const Submit: Story = { play: async () => { const canvas = within(getCanvasElementForDropdownTesting()); - expect(submitJestFn).toHaveBeenCalledTimes(0); + expect(handleSubmitMocked).toHaveBeenCalledTimes(0); const item = await canvas.findByText('Linkedin', undefined, { timeout: 3000, @@ -163,7 +166,7 @@ export const Submit: Story = { await userEvent.click(item); await waitFor(() => { - expect(submitJestFn).toHaveBeenCalledTimes(1); + expect(handleSubmitMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -172,12 +175,12 @@ export const Cancel: Story = { play: async () => { const canvas = within(getCanvasElementForDropdownTesting()); - expect(cancelJestFn).toHaveBeenCalledTimes(0); + expect(handleCancelMocked).toHaveBeenCalledTimes(0); await canvas.findByText('Linkedin', undefined, { timeout: 3000 }); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); await userEvent.click(emptyDiv); - expect(cancelJestFn).toHaveBeenCalledTimes(1); + expect(handleCancelMocked).toHaveBeenCalledTimes(1); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx index 6af5b11f6ba..f479bf012e3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx @@ -1,7 +1,6 @@ -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect } from 'react'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; @@ -14,29 +13,25 @@ import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { RichTextFieldInput } from '../RichTextFieldInput'; +import { getFieldInputEventContextProviderWithJestMocks } from './utils/getFieldInputEventContextProviderWithJestMocks'; -const clickOutsideJestFn = fn(); -const escapeJestFn = fn(); +const targetableObjectId = 'test-id'; -type RichTextFieldInputWithContextProps = { - targetableObjectId?: string; - onClickOutside?: typeof clickOutsideJestFn; - onEscape?: typeof escapeJestFn; -}; +const { + FieldInputEventContextProviderWithJestMocks, + handleEscapeMocked, + handleClickoutsideMocked, +} = getFieldInputEventContextProviderWithJestMocks(); const clearMocksDecorator: Decorator = (Story, context) => { if (context.parameters.clearMocks !== false) { - clickOutsideJestFn.mockClear(); - escapeJestFn.mockClear(); + handleClickoutsideMocked.mockClear(); + handleEscapeMocked.mockClear(); } return ; }; -const RichTextFieldInputWithContext = ({ - targetableObjectId = 'test-id', - onClickOutside, - onEscape, -}: RichTextFieldInputWithContextProps) => { +const RichTextFieldInputWithContext = () => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const instanceId = getRecordFieldInputInstanceId({ @@ -78,14 +73,9 @@ const RichTextFieldInputWithContext = ({ isRecordFieldReadOnly: false, }} > - + + +
@@ -97,8 +87,8 @@ const meta: Meta = { component: RichTextFieldInputWithContext, args: { targetableObjectId: 'test-id', - onClickOutside: clickOutsideJestFn, - onEscape: escapeJestFn, + onClickOutside: handleClickoutsideMocked, + onEscape: handleEscapeMocked, }, argTypes: { onClickOutside: { control: false }, @@ -124,13 +114,13 @@ export const Default: Story = {}; export const Escape: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(escapeJestFn).toHaveBeenCalledTimes(0); + expect(handleEscapeMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('click-outside-element'); await userEvent.keyboard('{esc}'); await waitFor(() => { - expect(escapeJestFn).toHaveBeenCalledTimes(1); + expect(handleEscapeMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -138,14 +128,14 @@ export const Escape: Story = { export const ClickOutside: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); const outsideElement = await canvas.findByTestId('click-outside-element'); await userEvent.click(outsideElement); await waitFor(() => { - expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(1); }); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx index 4c4372605e1..88c84c30861 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx @@ -1,9 +1,10 @@ -import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { useEffect, useState } from 'react'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { getFieldInputEventContextProviderWithJestMocks } from '@/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RECORD_TABLE_CELL_INPUT_ID_PREFIX } from '@/object-record/record-table/constants/RecordTableCellInputIdPrefix'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; @@ -13,7 +14,8 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { useTextField } from '../../../hooks/useTextField'; -import { TextFieldInput, type TextFieldInputProps } from '../TextFieldInput'; +import { TextFieldInput } from '../TextFieldInput'; + const TextFieldValueSetterEffect = ({ value }: { value: string }) => { const { setFieldValue } = useTextField(); @@ -24,7 +26,16 @@ const TextFieldValueSetterEffect = ({ value }: { value: string }) => { return <>; }; -type TextFieldInputWithContextProps = TextFieldInputProps & { +const { + FieldInputEventContextProviderWithJestMocks, + handleEscapeMocked, + handleClickoutsideMocked, + handleEnterMocked, + handleShiftTabMocked, + handleTabMocked, +} = getFieldInputEventContextProviderWithJestMocks(); + +type TextFieldInputWithContextProps = { value: string; recordId: string; }; @@ -32,11 +43,6 @@ type TextFieldInputWithContextProps = TextFieldInputProps & { const TextFieldInputWithContext = ({ recordId, value, - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, }: TextFieldInputWithContextProps) => { const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const [isReady, setIsReady] = useState(false); @@ -85,14 +91,10 @@ const TextFieldInputWithContext = ({ isRecordFieldReadOnly: false, }} > - - + + + + {isReady &&
}
@@ -100,19 +102,13 @@ const TextFieldInputWithContext = ({ ); }; -const enterJestFn = fn(); -const escapeJestfn = fn(); -const clickOutsideJestFn = fn(); -const tabJestFn = fn(); -const shiftTabJestFn = fn(); - const clearMocksDecorator: Decorator = (Story, context) => { if (context.parameters.clearMocks === true) { - enterJestFn.mockClear(); - escapeJestfn.mockClear(); - clickOutsideJestFn.mockClear(); - tabJestFn.mockClear(); - shiftTabJestFn.mockClear(); + handleEnterMocked.mockClear(); + handleEscapeMocked.mockClear(); + handleClickoutsideMocked.mockClear(); + handleTabMocked.mockClear(); + handleShiftTabMocked.mockClear(); } return ; }; @@ -122,11 +118,11 @@ const meta: Meta = { component: TextFieldInputWithContext, args: { value: 'text', - onEnter: enterJestFn, - onEscape: escapeJestfn, - onClickOutside: clickOutsideJestFn, - onTab: tabJestFn, - onShiftTab: shiftTabJestFn, + onEnter: handleEnterMocked, + onEscape: handleEscapeMocked, + onClickOutside: handleClickoutsideMocked, + onTab: handleTabMocked, + onShiftTab: handleShiftTabMocked, }, argTypes: { onEnter: { control: false }, @@ -151,13 +147,13 @@ export const Enter: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(enterJestFn).toHaveBeenCalledTimes(0); + expect(handleEnterMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{enter}'); await waitFor(() => { - expect(enterJestFn).toHaveBeenCalledTimes(1); + expect(handleEnterMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -166,13 +162,13 @@ export const Escape: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(escapeJestfn).toHaveBeenCalledTimes(0); + expect(handleEscapeMocked).toHaveBeenCalledTimes(0); await canvas.findByTestId('is-ready-marker'); await userEvent.keyboard('{esc}'); await waitFor(() => { - expect(escapeJestfn).toHaveBeenCalledTimes(1); + expect(handleEscapeMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -181,7 +177,7 @@ export const ClickOutside: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + expect(handleClickoutsideMocked).toHaveBeenCalledTimes(0); const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); await canvas.findByTestId('is-ready-marker'); @@ -189,7 +185,7 @@ export const ClickOutside: Story = { await userEvent.click(emptyDiv); await waitFor(() => { - expect(clickOutsideJestFn).toHaveBeenCalled(); + expect(handleClickoutsideMocked).toHaveBeenCalled(); }); }, }; @@ -200,12 +196,12 @@ export const Tab: Story = { await canvas.findByTestId('is-ready-marker'); - expect(tabJestFn).toHaveBeenCalledTimes(0); + expect(handleTabMocked).toHaveBeenCalledTimes(0); await userEvent.keyboard('{tab}'); await waitFor(() => { - expect(tabJestFn).toHaveBeenCalledTimes(1); + expect(handleTabMocked).toHaveBeenCalledTimes(1); }); }, }; @@ -216,12 +212,12 @@ export const ShiftTab: Story = { await canvas.findByTestId('is-ready-marker'); - expect(shiftTabJestFn).toHaveBeenCalledTimes(0); + expect(handleShiftTabMocked).toHaveBeenCalledTimes(0); await userEvent.keyboard('{shift>}{tab}'); await waitFor(() => { - expect(shiftTabJestFn).toHaveBeenCalledTimes(1); + expect(handleShiftTabMocked).toHaveBeenCalledTimes(1); }); }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks.tsx new file mode 100644 index 00000000000..e681515b188 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/utils/getFieldInputEventContextProviderWithJestMocks.tsx @@ -0,0 +1,46 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; +import { fn } from '@storybook/test'; +import { type PropsWithChildren } from 'react'; + +type FieldInputEventContextProviderWithJestMocksProps = PropsWithChildren; + +export const getFieldInputEventContextProviderWithJestMocks = () => { + const handleSubmitMocked = fn(); + const handleCancelMocked = fn(); + const handleClickoutsideMocked = fn(); + const handleEnterMocked = fn(); + const handleEscapeMocked = fn(); + const handleShiftTabMocked = fn(); + const handleTabMocked = fn(); + + const FieldInputEventContextProviderWithJestMocks = ({ + children, + }: FieldInputEventContextProviderWithJestMocksProps) => { + return ( + + {children} + + ); + }; + + return { + FieldInputEventContextProviderWithJestMocks, + handleSubmitMocked, + handleCancelMocked, + handleClickoutsideMocked, + handleEnterMocked, + handleEscapeMocked, + handleShiftTabMocked, + handleTabMocked, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputEvent.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputEvent.ts deleted file mode 100644 index d8f033259ce..00000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type FieldInputEvent = (persist: () => void) => void; - -export type FieldInputClickOutsideEvent = ( - persist: () => void, - event: MouseEvent | TouchEvent, -) => void; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index 865a70a2457..20a43d4fc61 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -5,14 +5,16 @@ import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { useOpenFieldInputEditMode } from '@/object-record/record-field/hooks/useOpenFieldInputEditMode'; +import { + FieldInputEventContext, + type FieldInputClickOutsideEvent, + type FieldInputEvent, +} from '@/object-record/record-field/contexts/FieldInputEventContext'; +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/hooks/usePersistFieldFromFieldInputContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { isInlineCellInEditModeFamilyState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeFamilyState'; import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; @@ -98,13 +100,22 @@ export const RecordInlineCell = ({ goBackToPreviousDropdownFocusId, ]); - const handleEnter: FieldInputEvent = (persistField) => { - persistField(); + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }; - const handleSubmit: FieldInputEvent = (persistField) => { - persistField(); + const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }; @@ -112,23 +123,37 @@ export const RecordInlineCell = ({ closeInlineCell(); }; - const handleEscape = () => { + const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }; - const handleTab: FieldInputEvent = (persistField) => { - persistField(); + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }; - const handleShiftTab: FieldInputEvent = (persistField) => { - persistField(); + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }; - const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( + const handleClickOutside = useRecoilCallback( ({ snapshot }) => - (persistField, event) => { + ({ + event, + newValue, + skipPersist, + }: Parameters[0]) => { const currentDropdownFocusId = snapshot .getLoadable(activeDropdownFocusIdState) .getValue(); @@ -142,13 +167,22 @@ export const RecordInlineCell = ({ if (currentDropdownFocusId !== expectedDropdownFocusId) { return; } - event.preventDefault(); - event.stopImmediatePropagation(); - persistField(); + event?.preventDefault(); + event?.stopImmediatePropagation(); + + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeInlineCell(); }, - [closeInlineCell, recordId, fieldDefinition.fieldMetadataId], + [ + closeInlineCell, + recordId, + fieldDefinition.fieldMetadataId, + persistFieldFromFieldInputContext, + ], ); const { getIcon } = useIcons(); @@ -163,18 +197,7 @@ export const RecordInlineCell = ({ labelWidth: fieldDefinition.labelWidth, showLabel: fieldDefinition.showLabel, isCentered, - editModeContent: ( - - ), + editModeContent: , displayModeContent: , isDisplayModeFixHeight: isDisplayModeFixHeight, editModeContentOnly: isFieldInputOnly, @@ -184,10 +207,22 @@ export const RecordInlineCell = ({ }; return ( - - - - - + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx index 6a5c3175441..7d45c094da9 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx @@ -20,10 +20,10 @@ import { RecordDetailDuplicatesSection } from '@/object-record/record-show/recor import { RecordDetailRelationSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSection'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; type FieldsCardProps = { objectNameSingular: string; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx index 7c324e43afd..2eb86269c16 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx @@ -17,9 +17,9 @@ import { type RecordUpdateHook, type RecordUpdateHookParams, } from '@/object-record/record-field/contexts/FieldContext'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useIsRecordReadOnly } from '@/object-record/record-field/hooks/read-only/useIsRecordReadOnly'; import { isRecordFieldReadOnly } from '@/object-record/record-field/hooks/read-only/utils/isRecordFieldReadOnly'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { type FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; @@ -113,6 +113,8 @@ export const RecordDetailRelationRecordsListItem = ({ isRecordFieldReadOnly: parentIsRecordFieldReadOnly, } = useContext(FieldContext); + const { onSubmit } = useContext(FieldInputEventContext); + const { openModal } = useModal(); const { @@ -137,8 +139,6 @@ export const RecordDetailRelationRecordsListItem = ({ relationObjectMetadataItem.id, ); - const persistField = usePersistField(); - const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({ objectNameSingular: relationObjectMetadataNameSingular, }); @@ -189,7 +189,7 @@ export const RecordDetailRelationRecordsListItem = ({ if (!relationFieldMetadataItem?.name) return; if (isToOneObject) { - persistField(null); + onSubmit?.({ newValue: null }); } else { updateOneRelationRecord({ idToUpdate: relationRecord.id, diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx index 9775de4f2de..a62ce5eec10 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx @@ -2,9 +2,15 @@ import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { type RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { + FieldInputEventContext, + type FieldInputEvent, +} from '@/object-record/record-field/contexts/FieldInputEventContext'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { type FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { RecordDetailRelationRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList'; import { RecordDetailRelationSectionDropdown } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdown'; @@ -40,6 +46,7 @@ export const RecordDetailRelationSection = ({ relationFieldMetadataId, relationObjectMetadataNameSingular, relationType, + objectMetadataNameSingular, } = fieldDefinition.metadata as FieldRelationMetadata; const isMobile = useIsMobile(); @@ -119,34 +126,61 @@ export const RecordDetailRelationSection = ({ }, }); + // TODO: refactor this when we have refactored columnDefinitions and field definitions because + // we should be able to get the objectMetadataItem from a context way more easily + const { objectMetadataItems } = useObjectMetadataItems(); + + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItemToFind) => + objectMetadataItemToFind.nameSingular === objectMetadataNameSingular, + ); + + const persistField = usePersistField({ + objectMetadataItemId: objectMetadataItem?.id ?? '', + }); + + const handleSubmit: FieldInputEvent = ({ newValue }) => { + persistField({ + recordId: recordId, + fieldDefinition, + valueToPersist: newValue, + }); + }; + if (loading) return null; const relationRecordsCount = relationAggregateResult?.id?.COUNT ?? 0; return ( - - 0 - ? t`All (${relationRecordsCount})` - : '', - } - : undefined - } - hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile} - areRecordsAvailable={relationRecords.length > 0} - rightAdornment={ - - } - /> - {relationRecords.length > 0 && ( - - )} - + + + 0 + ? t`All (${relationRecordsCount})` + : '', + } + : undefined + } + hideRightAdornmentOnMouseLeave={!isDropdownOpen && !isMobile} + areRecordsAvailable={relationRecords.length > 0} + rightAdornment={ + + } + /> + {relationRecords.length > 0 && ( + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdownToOne.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdownToOne.tsx index 9007b4570b3..c1cfa6b06be 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdownToOne.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSectionDropdownToOne.tsx @@ -4,7 +4,7 @@ import { useRecoilValue } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer'; import { type FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker'; @@ -74,7 +74,7 @@ export const RecordDetailRelationSectionDropdownToOne = () => { setSingleRecordPickerSearchFilter(''); }, [setSingleRecordPickerSearchFilter]); - const persistField = usePersistField(); + const { onSubmit } = useContext(FieldInputEventContext); const handleRelationPickerEntitySelected = ( selectedRelationEntity?: SingleRecordPickerRecord, @@ -83,7 +83,7 @@ export const RecordDetailRelationSectionDropdownToOne = () => { if (!selectedRelationEntity?.id || !relationFieldMetadataItem?.name) return; - persistField(selectedRelationEntity.record); + onSubmit?.({ newValue: selectedRelationEntity.record }); }; const { createNewRecordAndOpenRightDrawer } = diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx index c686d45ee76..00bba1942aa 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx @@ -1,33 +1,41 @@ import { FieldInput } from '@/object-record/record-field/components/FieldInput'; -import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/hooks/usePersistFieldFromFieldInputContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; + import { + FieldInputEventContext, type FieldInputClickOutsideEvent, type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; +} from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext'; import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId'; -import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; export const RecordTableCellFieldInput = () => { const { onMoveFocus, onCloseTableCell } = useRecordTableBodyContextOrThrow(); - const { isRecordFieldReadOnly: isReadOnly } = useContext(FieldContext); + const instanceId = useAvailableComponentInstanceId( RecordFieldComponentInstanceContext, ); - const handleEnter: FieldInputEvent = (persistField) => { - persistField(); + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } onCloseTableCell(); onMoveFocus('down'); }; - const handleSubmit: FieldInputEvent = (persistField) => { - persistField(); + const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } onCloseTableCell(); }; @@ -38,7 +46,7 @@ export const RecordTableCellFieldInput = () => { const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( ({ snapshot }) => - (persistField, event) => { + ({ newValue, event, skipPersist }) => { const currentFocusId = snapshot .getLoadable(currentFocusIdSelector) .getValue(); @@ -46,44 +54,57 @@ export const RecordTableCellFieldInput = () => { if (currentFocusId !== instanceId) { return; } - event.preventDefault(); - event.stopImmediatePropagation(); - persistField(); + event?.preventDefault(); + event?.stopImmediatePropagation(); + + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + onCloseTableCell(); }, - [onCloseTableCell, instanceId], + [onCloseTableCell, instanceId, persistFieldFromFieldInputContext], ); - const handleEscape: FieldInputEvent = (persistField) => { - persistField(); + const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } onCloseTableCell(); }; - const handleTab: FieldInputEvent = (persistField) => { - persistField(); + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } onCloseTableCell(); onMoveFocus('right'); }; - const handleShiftTab: FieldInputEvent = (persistField) => { - persistField(); + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } onCloseTableCell(); onMoveFocus('left'); }; return ( - + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCell.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCell.tsx index 44d5ab1208a..e4a5c114d03 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCell.tsx @@ -1,13 +1,15 @@ -import { useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldFocusContextProvider } from '@/object-record/record-field/contexts/FieldFocusContextProvider'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; + import { + FieldInputEventContext, type FieldInputClickOutsideEvent, type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; - +} from '@/object-record/record-field/contexts/FieldInputEventContext'; +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/hooks/usePersistFieldFromFieldInputContext'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext'; import { RecordTitleCellContainer } from '@/object-record/record-title-cell/components/RecordTitleCellContainer'; import { @@ -19,6 +21,7 @@ import { RecordTitleCellFieldInput } from '@/object-record/record-title-cell/com import { useRecordTitleCell } from '@/object-record/record-title-cell/hooks/useRecordTitleCell'; import { type RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { useRecoilCallback } from 'recoil'; type RecordTitleCellProps = { loading?: boolean; @@ -38,53 +41,77 @@ export const RecordTitleCell = ({ const { closeRecordTitleCell } = useRecordTitleCell(); - const closeCell = () => { + const closeCell = useCallback(() => { closeRecordTitleCell({ recordId, fieldName: fieldDefinition.metadata.fieldName, containerType, }); - }; + }, [closeRecordTitleCell, containerType, fieldDefinition, recordId]); - const handleEnter: FieldInputEvent = (persistField) => { - closeCell(); - persistField(); - }; + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } - const handleEscape = () => { closeCell(); }; - const handleTab: FieldInputEvent = (persistField) => { + const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( + () => + ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeCell(); + }, + [closeCell, persistFieldFromFieldInputContext], + ); + + const handleEscape: FieldInputEvent = () => { closeCell(); - persistField(); }; - const handleShiftTab: FieldInputEvent = (persistField) => { + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeCell(); - persistField(); }; - const handleClickOutside: FieldInputClickOutsideEvent = (persistField) => { + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + closeCell(); - persistField(); }; const recordTitleCellContextValue: RecordTitleCellContextProps = { editModeContent: ( - + + + ), displayModeContent: ( diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldInput.tsx index b19447fea3a..7871f7f4e12 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellFieldInput.tsx @@ -1,7 +1,6 @@ import { useContext } from 'react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; -import { type FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { RecordTitleCellTextFieldInput } from '@/object-record/record-title-cell/components/RecordTitleCellTextFieldInput'; @@ -9,25 +8,12 @@ import { RecordTitleFullNameFieldInput } from '@/object-record/record-title-cell type RecordTitleCellFieldInputProps = { instanceId: string; - onClickOutside?: ( - persist: () => void, - event: MouseEvent | TouchEvent, - ) => void; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; sizeVariant?: 'xs' | 'md'; }; export const RecordTitleCellFieldInput = ({ instanceId, sizeVariant, - onEnter, - onEscape, - onShiftTab, - onTab, - onClickOutside, }: RecordTitleCellFieldInputProps) => { const { fieldDefinition } = useContext(FieldContext); @@ -40,22 +26,10 @@ export const RecordTitleCellFieldInput = ({ {isFieldText(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? ( - + ) : null} ); diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldInput.tsx index 1552af56d89..d6d53baa57b 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleCellTextFieldInput.tsx @@ -1,33 +1,20 @@ -import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useTextField } from '@/object-record/record-field/meta-types/hooks/useTextField'; import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + import { TextInput } from '@/ui/input/components/TextInput'; -import { useRef } from 'react'; +import { useContext, useRef } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndefinedIfWhitespacesOnly'; type RecordTitleCellTextFieldInputProps = { instanceId: string; - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; sizeVariant?: 'xs' | 'md'; }; export const RecordTitleCellTextFieldInput = ({ instanceId, sizeVariant, - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, }: RecordTitleCellTextFieldInputProps) => { const { fieldDefinition, draftValue, setDraftValue } = useTextField(); @@ -37,26 +24,28 @@ export const RecordTitleCellTextFieldInput = ({ setDraftValue(turnIntoUndefinedIfWhitespacesOnly(newText)); }; - const persistField = usePersistField(); + const { onEnter, onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); useRegisterInputEvents({ focusId: instanceId, inputRef: wrapperRef, inputValue: draftValue ?? '', onEnter: (inputValue) => { - onEnter?.(() => persistField(inputValue)); + onEnter?.({ newValue: inputValue }); }, onEscape: (inputValue) => { - onEscape?.(() => persistField(inputValue)); + onEscape?.({ newValue: inputValue }); }, onClickOutside: (event, inputValue) => { - onClickOutside?.(() => persistField(inputValue), event); + onClickOutside?.({ newValue: inputValue, event }); }, onTab: (inputValue) => { - onTab?.(() => persistField(inputValue)); + onTab?.({ newValue: inputValue }); }, onShiftTab: (inputValue) => { - onShiftTab?.(() => persistField(inputValue)); + onShiftTab?.({ newValue: inputValue }); }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldInput.tsx index 00264f0a4b9..4842fe84e2f 100644 --- a/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-title-cell/components/RecordTitleFullNameFieldInput.tsx @@ -1,33 +1,25 @@ +import { FieldInputEventContext } from '@/object-record/record-field/contexts/FieldInputEventContext'; import { useFullNameField } from '@/object-record/record-field/meta-types/hooks/useFullNameField'; import { FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/FirstNamePlaceholder'; import { LAST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS } from '@/object-record/record-field/meta-types/input/constants/LastNamePlaceholder'; import { isDoubleTextFieldEmpty } from '@/object-record/record-field/meta-types/input/utils/isDoubleTextFieldEmpty'; import { type FieldDoubleText } from '@/object-record/record-field/types/FieldDoubleText'; -import { - type FieldInputClickOutsideEvent, - type FieldInputEvent, -} from '@/object-record/record-field/types/FieldInputEvent'; + +import { useContext } from 'react'; import { RecordTitleDoubleTextInput } from './RecordTitleDoubleTextInput'; type RecordTitleFullNameFieldInputProps = { - onClickOutside?: FieldInputClickOutsideEvent; - onEnter?: FieldInputEvent; - onEscape?: FieldInputEvent; - onTab?: FieldInputEvent; - onShiftTab?: FieldInputEvent; sizeVariant?: 'xs' | 'md'; }; export const RecordTitleFullNameFieldInput = ({ - onEnter, - onEscape, - onClickOutside, - onTab, - onShiftTab, sizeVariant, }: RecordTitleFullNameFieldInputProps) => { - const { draftValue, setDraftValue, persistFullNameField } = - useFullNameField(); + const { draftValue, setDraftValue } = useFullNameField(); + + const { onEnter, onEscape, onClickOutside, onTab, onShiftTab } = useContext( + FieldInputEventContext, + ); const convertToFullName = (newDoubleText: FieldDoubleText) => { return { @@ -45,29 +37,26 @@ export const RecordTitleFullNameFieldInput = ({ }; const handleEnter = (newDoubleText: FieldDoubleText) => { - onEnter?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onEnter?.({ newValue: convertToFullName(newDoubleText) }); }; const handleEscape = (newDoubleText: FieldDoubleText) => { - onEscape?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onEscape?.({ newValue: convertToFullName(newDoubleText) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, newDoubleText: FieldDoubleText, ) => { - onClickOutside?.( - () => persistFullNameField(convertToFullName(newDoubleText)), - event, - ); + onClickOutside?.({ newValue: convertToFullName(newDoubleText), event }); }; const handleTab = (newDoubleText: FieldDoubleText) => { - onTab?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onTab?.({ newValue: convertToFullName(newDoubleText) }); }; const handleShiftTab = (newDoubleText: FieldDoubleText) => { - onShiftTab?.(() => persistFullNameField(convertToFullName(newDoubleText))); + onShiftTab?.({ newValue: convertToFullName(newDoubleText) }); }; const handleChange = (newDoubleText: FieldDoubleText) => { diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx index c63ea436190..89c91bc10ab 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/preview/components/SettingsDataModelFieldPreview.tsx @@ -150,14 +150,18 @@ export const SettingsDataModelFieldPreview = ({ }, defaultValue: fieldMetadataItem.defaultValue, }, - isRecordFieldReadOnly: false, + isRecordFieldReadOnly: + fieldMetadataItem.type === FieldMetadataType.BOOLEAN || + fieldMetadataItem.type === FieldMetadataType.RATING + ? true + : false, disableChipClick: true, }} > {fieldMetadataItem.type === FieldMetadataType.BOOLEAN ? ( - + ) : fieldMetadataItem.type === FieldMetadataType.RATING ? ( - + ) : ( )}