diff --git a/package.json b/package.json index 4b3c5a1475..e1c6a4e059 100644 --- a/package.json +++ b/package.json @@ -241,6 +241,7 @@ "msw": "^2.0.11", "msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0", "nx": "^17.2.8", + "playwright": "^1.40.1", "prettier": "^3.1.1", "raw-loader": "^4.0.2", "rimraf": "^5.0.5", diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 4d95ea994b..f5f2abb0ea 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -9,14 +9,16 @@ "start:clean": "yarn start --force", "build": "tsc && vite build && yarn build:inject-runtime-env", "build:inject-runtime-env": "sh ./scripts/inject-runtime-env.sh", + "tsc:spec": "tsc --project tsconfig.spec.json --noEmit", "tsc": "tsc --project tsconfig.app.json --watch", - "tsc:ci": "tsc --project tsconfig.app.json", + "tsc:ci": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.spec.json --noEmit && tsc --project tsconfig.node.json --noEmit", "preview": "vite preview", "lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs", "lint:ci": "yarn lint --config .eslintrc-ci.cjs", "fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\"", "fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"", "test": "jest", + "test-watch": "jest --coverage=false --watch", "tsup": "tsup", "coverage": "jest --coverage", "storybook:modules:dev": "STORYBOOK_SCOPE=modules yarn storybook:dev", @@ -29,6 +31,7 @@ "storybook:docs:build": "STORYBOOK_SCOPE=ui-docs yarn storybook:build", "storybook:test": "test-storybook", "storybook:test-slow": "test-storybook --maxWorkers=3", + "storybook:test-single-worker": "test-storybook --maxWorkers=1", "storybook:coverage": "yarn storybook:test-slow --coverage && npx nyc report --reporter=lcov -t coverage/storybook --report-dir coverage/storybook --check-coverage", "storybook:modules:coverage": "STORYBOOK_SCOPE=modules yarn storybook:coverage", "storybook:pages:coverage": "STORYBOOK_SCOPE=pages yarn storybook:coverage", diff --git a/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts b/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts index e622e0c1f4..68e3c83953 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/optimistic-effect-definition/getRecordOptimisticEffectDefinition.ts @@ -4,7 +4,7 @@ import { produce } from 'immer'; import { OptimisticEffectDefinition } from '@/apollo/optimistic-effect/types/OptimisticEffectDefinition'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; -import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; @@ -21,77 +21,39 @@ export const getRecordOptimisticEffectDefinition = ({ deletedRecordIds, variables, }) => { - const newRecordPaginatedCacheField = produce< - PaginatedRecordTypeResults - >(currentData as PaginatedRecordTypeResults, (draft) => { - const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0]; + const newRecordPaginatedCacheField = produce>( + currentData as ObjectRecordConnection, + (draft) => { + const existingDataIsEmpty = !draft || !draft.edges || !draft.edges[0]; - if (isNonEmptyArray(createdRecords)) { - if (existingDataIsEmpty) { - return { - __typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - edges: createdRecords.map((createdRecord) => ({ - node: createdRecord, - cursor: '', - })), - pageInfo: { - endCursor: '', - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - }, - }; - } else { - for (const createdRecord of createdRecords) { - const existingRecord = draft.edges.find( - (edge) => edge.node.id === createdRecord.id, - ); - - if (existingRecord) { - existingRecord.node = createdRecord; - continue; - } - - draft.edges.unshift({ - node: createdRecord, - cursor: '', + if (isNonEmptyArray(createdRecords)) { + if (existingDataIsEmpty) { + return { __typename: `${capitalize(objectMetadataItem.nameSingular)}Edge`, - }); - } - } - } - - if (isNonEmptyArray(deletedRecordIds)) { - draft.edges = draft.edges.filter( - (edge) => !deletedRecordIds.includes(edge.node.id), - ); - } - - if (isNonEmptyArray(updatedRecords)) { - for (const updatedRecord of updatedRecords) { - const updatedRecordIsOutOfQueryFilter = - isDefined(variables.filter) && - !isRecordMatchingFilter({ - record: updatedRecord, - filter: variables.filter, - objectMetadataItem, - }); - - if (updatedRecordIsOutOfQueryFilter) { - draft.edges = draft.edges.filter( - (edge) => edge.node.id !== updatedRecord.id, - ); + edges: createdRecords.map((createdRecord) => ({ + node: createdRecord, + cursor: '', + })), + pageInfo: { + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + }, + }; } else { - const foundUpdatedRecordInCacheQuery = draft.edges.find( - (edge) => edge.node.id === updatedRecord.id, - ); + for (const createdRecord of createdRecords) { + const existingRecord = draft.edges.find( + (edge) => edge.node.id === createdRecord.id, + ); - if (foundUpdatedRecordInCacheQuery) { - foundUpdatedRecordInCacheQuery.node = updatedRecord; - } else { - // TODO: add order by - draft.edges.push({ - node: updatedRecord, + if (existingRecord) { + existingRecord.node = createdRecord; + continue; + } + + draft.edges.unshift({ + node: createdRecord, cursor: '', __typename: `${capitalize( objectMetadataItem.nameSingular, @@ -100,8 +62,49 @@ export const getRecordOptimisticEffectDefinition = ({ } } } - } - }); + + if (isNonEmptyArray(deletedRecordIds)) { + draft.edges = draft.edges.filter( + (edge) => !deletedRecordIds.includes(edge.node.id), + ); + } + + if (isNonEmptyArray(updatedRecords)) { + for (const updatedRecord of updatedRecords) { + const updatedRecordIsOutOfQueryFilter = + isDefined(variables.filter) && + !isRecordMatchingFilter({ + record: updatedRecord, + filter: variables.filter, + objectMetadataItem, + }); + + if (updatedRecordIsOutOfQueryFilter) { + draft.edges = draft.edges.filter( + (edge) => edge.node.id !== updatedRecord.id, + ); + } else { + const foundUpdatedRecordInCacheQuery = draft.edges.find( + (edge) => edge.node.id === updatedRecord.id, + ); + + if (foundUpdatedRecordInCacheQuery) { + foundUpdatedRecordInCacheQuery.node = updatedRecord; + } else { + // TODO: add order by + draft.edges.push({ + node: updatedRecord, + cursor: '', + __typename: `${capitalize( + objectMetadataItem.nameSingular, + )}Edge`, + }); + } + } + } + } + }, + ); return newRecordPaginatedCacheField; }, diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts new file mode 100644 index 0000000000..66b7e45d49 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts @@ -0,0 +1,752 @@ +import { Company } from '@/companies/types/Company'; +import { Favorite } from '@/favorites/types/Favorite'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { Person } from '@/people/types/Person'; + +export const emptyConnectionMock: ObjectRecordConnection = { + edges: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + __typename: 'ObjectRecordConnection', +}; + +export const companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock: ObjectRecordConnection< + Partial & + Pick & { + people: ObjectRecordConnection< + Pick & { + favorites: ObjectRecordConnection< + Pick + >; + } + >; + } +> = { + pageInfo: { + endCursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', + }, + edges: [ + { + cursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', + node: { + id: '04b2e9f5-0713-40a5-8216-82802401d33e', + name: 'Qonto', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyIwZDk0MDk5Ny1jMjFlLTRlYzItODczYi1kZTQyNjRkODkwMjUiXQ==', + node: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + people: { + edges: [ + { + cursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049190df', + name: { + firstName: 'Bertrand', + lastName: 'Voulzy', + }, + favorites: { + edges: [ + { + cursor: + 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', + node: { + id: 'c85a867c-5a8f-4861-8ed2-96c390248423', + personId: '240da2ec-2d40-4e49-8df4-9c6a049190df', + companyId: null, + position: 2, + }, + }, + ], + pageInfo: { + endCursor: + 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', + }, + }, + }, + }, + { + cursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049190ef', + name: { + firstName: 'Madison', + lastName: 'Perez', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==', + node: { + id: '56955422-5d54-41b7-ba36-f0d20e1417ae', + name: { + firstName: 'Avery', + lastName: 'Carter', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==', + node: { + id: '755035db-623d-41fe-92e7-dd45b7c568e1', + name: { + firstName: 'Ethan', + lastName: 'Mitchell', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==', + node: { + id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35', + name: { + firstName: 'Elizabeth', + lastName: 'Baker', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', + node: { + id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016', + name: { + firstName: 'Christopher', + lastName: 'Nelson', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', + }, + }, + }, + }, + { + cursor: 'WyIxMTg5OTVmMy01ZDgxLTQ2ZDYtYmY4My1mN2ZkMzNlYTYxMDIiXQ==', + node: { + id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', + name: 'Facebook', + people: { + edges: [ + { + cursor: + 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', + node: { + id: '93c72d2e-f517-42fd-80ae-14173b3b70ae', + name: { + firstName: 'Christopher', + lastName: 'Gonzalez', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', + node: { + id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e', + name: { + firstName: 'Ashley', + lastName: 'Parker', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', + }, + }, + }, + }, + { + cursor: 'WyIxZDNhMWM2ZS03MDdlLTQ0ZGMtYTFkMi0zMDAzMGJmMWE5NDQiXQ==', + node: { + id: '1d3a1c6e-707e-44dc-a1d2-30030bf1a944', + name: 'Netflix', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyI0NjBiNmZiMS1lZDg5LTQxM2EtYjMxYS05NjI5ODZlNjdiYjQiXQ==', + node: { + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + name: 'Microsoft', + people: { + edges: [ + { + cursor: + 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', + node: { + id: '1d151852-490f-4466-8391-733cfd66a0c8', + name: { + firstName: 'Isabella', + lastName: 'Scott', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==', + node: { + id: '98406e26-80f1-4dff-b570-a74942528de3', + name: { + firstName: 'Matthew', + lastName: 'Green', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: + 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', + node: { + id: '9b324a88-6784-4449-afdf-dc62cb8702f2', + name: { + firstName: 'Nicholas', + lastName: 'Wright', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', + }, + }, + }, + }, + { + cursor: 'WyI3YTkzZDFlNS0zZjc0LTQ5MmQtYTEwMS0yYTcwZjUwYTE2NDUiXQ==', + node: { + id: '7a93d1e5-3f74-492d-a101-2a70f50a1645', + name: 'Libeo', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyI4OWJiODI1Yy0xNzFlLTRiY2MtOWNmNy00MzQ0OGQ2ZmIyNzgiXQ==', + node: { + id: '89bb825c-171e-4bcc-9cf7-43448d6fb278', + name: 'Airbnb', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyI5ZDE2MmRlNi1jZmJmLTQxNTYtYTc5MC1lMzk4NTRkY2Q0ZWIiXQ==', + node: { + id: '9d162de6-cfbf-4156-a790-e39854dcd4eb', + name: 'Claap', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyJhNjc0ZmE2Yy0xNDU1LTRjNTctYWZhZi1kZDVkYzA4NjM2MWQiXQ==', + node: { + id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d', + name: 'Algolia', + people: { + edges: [ + { + cursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049191df', + name: { + firstName: 'Lorie', + lastName: 'Vladim', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', + }, + }, + }, + }, + { + cursor: 'WyJhN2JjNjhkNS1mNzllLTQwZGQtYmQwNi1jMzZlNmFiYjQ2NzgiXQ==', + node: { + id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678', + name: 'Samsung', + people: { + edges: [ + { + cursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049191de', + name: { + firstName: 'Louis', + lastName: 'Duss', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', + }, + }, + }, + }, + { + cursor: 'WyJhYWZmY2ZiZC1mODZiLTQxOWYtYjc5NC0wMjMxOWFiZTg2MzciXQ==', + node: { + id: 'aaffcfbd-f86b-419f-b794-02319abe8637', + name: 'Hasura', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyJmMzNkYzI0Mi01NTE4LTQ1NTMtOTQzMy00MmQ4ZWI4MjgzNGIiXQ==', + node: { + id: 'f33dc242-5518-4553-9433-42d8eb82834b', + name: 'Wework', + people: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + { + cursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==', + node: { + id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', + name: 'Linkedin', + people: { + edges: [ + { + cursor: + 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', + node: { + id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', + name: { + firstName: 'Sylvie', + lastName: 'Palmer', + }, + favorites: { + edges: [ + { + cursor: + 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', + node: { + id: '37b97140-26b9-498c-837b-4f3de499ad83', + personId: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', + companyId: null, + position: 1, + }, + }, + ], + pageInfo: { + endCursor: + 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', + }, + }, + }, + }, + { + cursor: + 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', + node: { + id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', + name: { + firstName: 'Christoph', + lastName: 'Callisto', + }, + favorites: { + edges: [], + pageInfo: { + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + }, + }, + }, + }, + ], + pageInfo: { + endCursor: + 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', + }, + }, + }, + }, + ], +}; + +export const peopleWithTheirUniqueCompanies: ObjectRecordConnection< + Pick & { company: Pick } +> = { + pageInfo: { + endCursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', + hasNextPage: false, + hasPreviousPage: false, + startCursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', + }, + edges: [ + { + cursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', + node: { + id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', + company: { + id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', + name: 'Linkedin', + }, + }, + }, + { + cursor: 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', + node: { + id: '1d151852-490f-4466-8391-733cfd66a0c8', + company: { + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + name: 'Microsoft', + }, + }, + }, + { + cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049190df', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049190ef', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049191de', + company: { + id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678', + name: 'Samsung', + }, + }, + }, + { + cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', + node: { + id: '240da2ec-2d40-4e49-8df4-9c6a049191df', + company: { + id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d', + name: 'Algolia', + }, + }, + }, + { + cursor: 'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==', + node: { + id: '56955422-5d54-41b7-ba36-f0d20e1417ae', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==', + node: { + id: '755035db-623d-41fe-92e7-dd45b7c568e1', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', + node: { + id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', + company: { + id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', + name: 'Linkedin', + }, + }, + }, + { + cursor: 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', + node: { + id: '93c72d2e-f517-42fd-80ae-14173b3b70ae', + company: { + id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', + name: 'Facebook', + }, + }, + }, + { + cursor: 'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==', + node: { + id: '98406e26-80f1-4dff-b570-a74942528de3', + company: { + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + name: 'Microsoft', + }, + }, + }, + { + cursor: 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', + node: { + id: '9b324a88-6784-4449-afdf-dc62cb8702f2', + company: { + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + name: 'Microsoft', + }, + }, + }, + { + cursor: 'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==', + node: { + id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', + node: { + id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016', + company: { + id: '0d940997-c21e-4ec2-873b-de4264d89025', + name: 'Google', + }, + }, + }, + { + cursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', + node: { + id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e', + company: { + id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', + name: 'Facebook', + }, + }, + }, + ], +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx new file mode 100644 index 0000000000..6d58d3b7db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx @@ -0,0 +1,190 @@ +import { isNonEmptyArray } from '@sniptt/guards'; +import { renderHook } from '@testing-library/react'; + +import { Company } from '@/companies/types/Company'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, + emptyConnectionMock, + peopleWithTheirUniqueCompanies, +} from '@/object-record/hooks/__mocks__/useMapConnectionToRecords'; +import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; +import { Person } from '@/people/types/Person'; +import { getJestHookWrapper } from '~/testing/jest/getJestHookWrapper'; +import { isDefined } from '~/utils/isDefined'; + +const Wrapper = getJestHookWrapper({ + apolloMocks: [], + onInitializeRecoilSnapshot: (snapshot) => { + snapshot.set(objectMetadataItemsState, getObjectMetadataItemsMock()); + }, +}); + +describe('useMapConnectionToRecords', () => { + it('Empty edges - should return an empty array if no edge', async () => { + const { result } = renderHook( + () => { + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = mapConnectionToRecords({ + objectNameSingular: CoreObjectNameSingular.Company, + objectRecordConnection: emptyConnectionMock, + depth: 5, + }); + + return records; + }, + { + wrapper: Wrapper, + }, + ); + + expect(Array.isArray(result.current)).toBe(true); + }); + + it('No relation fields - should return an array of company records', async () => { + const { result } = renderHook( + () => { + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = mapConnectionToRecords({ + objectNameSingular: CoreObjectNameSingular.Company, + objectRecordConnection: + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, + depth: 5, + }); + + return records; + }, + { + wrapper: Wrapper, + }, + ); + + expect(Array.isArray(result.current)).toBe(true); + }); + + it('n+1 relation fields - should return an array of company records with their people records', async () => { + const { result } = renderHook( + () => { + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = mapConnectionToRecords({ + objectNameSingular: CoreObjectNameSingular.Company, + objectRecordConnection: + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, + depth: 5, + }); + + return records; + }, + { + wrapper: Wrapper, + }, + ); + + const secondCompanyMock = + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock + .edges[1]; + + const secondCompanyPeopleMock = secondCompanyMock.node.people.edges.map( + (edge) => edge.node, + ); + + const companiesResult = result.current; + const secondCompanyResult = result.current[1]; + const secondCompanyPeopleResult = secondCompanyResult.people; + + expect(isNonEmptyArray(companiesResult)).toBe(true); + expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id); + expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true); + expect(secondCompanyPeopleResult[0].id).toEqual( + secondCompanyPeopleMock[0].id, + ); + }); + + it('n+2 relation fields - should return an array of company records with their people records with their favorites records', async () => { + const { result } = renderHook( + () => { + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = mapConnectionToRecords({ + objectNameSingular: CoreObjectNameSingular.Company, + objectRecordConnection: + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, + depth: 5, + }); + + return records; + }, + { + wrapper: Wrapper, + }, + ); + + const secondCompanyMock = + companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock + .edges[1]; + + const secondCompanyPeopleMock = secondCompanyMock.node.people; + + const secondCompanyFirstPersonMock = secondCompanyPeopleMock.edges[0].node; + + const secondCompanyFirstPersonFavoritesMock = + secondCompanyFirstPersonMock.favorites; + + const companiesResult = result.current; + const secondCompanyResult = companiesResult[1]; + const secondCompanyPeopleResult = secondCompanyResult.people; + const secondCompanyFirstPersonResult = secondCompanyPeopleResult[0]; + const secondCompanyFirstPersonFavoritesResult = + secondCompanyFirstPersonResult.favorites; + + expect(isNonEmptyArray(companiesResult)).toBe(true); + expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id); + expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true); + expect(secondCompanyFirstPersonResult.id).toEqual( + secondCompanyFirstPersonMock.id, + ); + expect(isNonEmptyArray(secondCompanyFirstPersonFavoritesResult)).toBe(true); + expect(secondCompanyFirstPersonFavoritesResult[0].id).toEqual( + secondCompanyFirstPersonFavoritesMock.edges[0].node.id, + ); + }); + + it("n+1 relation field TO_ONE_OBJECT - should return an array of people records with their company, mapConnectionToRecords shouldn't try to parse TO_ONE_OBJECT", async () => { + const { result } = renderHook( + () => { + const mapConnectionToRecords = useMapConnectionToRecords(); + + const records = mapConnectionToRecords({ + objectNameSingular: CoreObjectNameSingular.Person, + objectRecordConnection: peopleWithTheirUniqueCompanies, + depth: 5, + }); + + return records as (Person & { company: Company })[]; + }, + { + wrapper: Wrapper, + }, + ); + + const firstPersonMock = peopleWithTheirUniqueCompanies.edges[0].node; + + const firstPersonsCompanyMock = firstPersonMock.company; + + const peopleResult = result.current; + + const firstPersonResult = result.current[0]; + const firstPersonsCompanyresult = firstPersonResult.company; + + expect(isNonEmptyArray(peopleResult)).toBe(true); + expect(firstPersonResult.id).toBe(firstPersonMock.id); + + expect(isDefined(firstPersonsCompanyresult)).toBe(true); + expect(firstPersonsCompanyresult.id).toEqual(firstPersonsCompanyMock.id); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index b23157f58d..a3773eda58 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -9,7 +9,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useRecordOptimisticEffect } from '@/object-metadata/hooks/useRecordOptimisticEffect'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; import { OrderByField } from '@/object-metadata/types/OrderByField'; +import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/search/hooks/useFilteredSearchEntityQuery'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; @@ -19,28 +23,24 @@ import { capitalize } from '~/utils/string/capitalize'; import { cursorFamilyState } from '../states/cursorFamilyState'; import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState'; -import { PaginatedRecordType } from '../types/PaginatedRecordType'; -import { - PaginatedRecordTypeEdge, - PaginatedRecordTypeResults, -} from '../types/PaginatedRecordTypeResults'; +import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult'; import { mapPaginatedRecordsToRecords } from '../utils/mapPaginatedRecordsToRecords'; -export const useFindManyRecords = < - RecordType extends { id: string } & Record, ->({ +export const useFindManyRecords = ({ objectNameSingular, filter, orderBy, limit = DEFAULT_SEARCH_REQUEST_LIMIT, onCompleted, skip, + useRecordsWithoutConnection = false, }: ObjectMetadataItemIdentifier & { filter?: ObjectRecordQueryFilter; orderBy?: OrderByField; limit?: number; - onCompleted?: (data: PaginatedRecordTypeResults) => void; + onCompleted?: (data: ObjectRecordConnection) => void; skip?: boolean; + useRecordsWithoutConnection?: boolean; }) => { const findManyQueryStateIdentifier = objectNameSingular + @@ -75,7 +75,7 @@ export const useFindManyRecords = < const currentWorkspace = useRecoilValue(currentWorkspaceState); const { data, loading, error, fetchMore } = useQuery< - PaginatedRecordType + ObjectRecordQueryResult >(findManyRecordsQuery, { skip: skip || !objectMetadataItem || !currentWorkspace, variables: { @@ -88,10 +88,10 @@ export const useFindManyRecords = < if (data?.[objectMetadataItem.namePlural]) { setLastCursor( - data?.[objectMetadataItem.namePlural]?.pageInfo.endCursor, + data?.[objectMetadataItem.namePlural]?.pageInfo.endCursor ?? '', ); setHasNextPage( - data?.[objectMetadataItem.namePlural]?.pageInfo.hasNextPage, + data?.[objectMetadataItem.namePlural]?.pageInfo.hasNextPage ?? false, ); } }, @@ -125,7 +125,7 @@ export const useFindManyRecords = < const nextEdges = fetchMoreResult?.[objectMetadataItem.namePlural]?.edges; - let newEdges: PaginatedRecordTypeEdge[] = []; + let newEdges: ObjectRecordEdge[] = []; if (isNonEmptyArray(previousEdges) && isNonEmptyArray(nextEdges)) { newEdges = filterUniqueRecordEdgesByCursor([ @@ -138,11 +138,11 @@ export const useFindManyRecords = < if (data?.[objectMetadataItem.namePlural]) { setLastCursor( fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo - .endCursor, + .endCursor ?? '', ); setHasNextPage( fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo - .hasNextPage, + .hasNextPage ?? false, ); } @@ -164,7 +164,7 @@ export const useFindManyRecords = < pageInfo: fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo, }, - } as PaginatedRecordType); + } as ObjectRecordQueryResult); }, }); } catch (error) { @@ -198,18 +198,39 @@ export const useFindManyRecords = < enqueueSnackBar, ]); + // TODO: remove this and use only mapConnectionToRecords when we've finished the refactor const records = useMemo( () => mapPaginatedRecordsToRecords({ pagedRecords: data, objectNamePlural: objectMetadataItem.namePlural, - }), + }) as T[], [data, objectMetadataItem], ); + const mapConnectionToRecords = useMapConnectionToRecords(); + + const recordsWithoutConnection = useMemo( + () => + useRecordsWithoutConnection + ? (mapConnectionToRecords({ + objectRecordConnection: data?.[objectMetadataItem.namePlural], + objectNameSingular, + depth: 5, + }) as T[]) + : [], + [ + data, + objectNameSingular, + objectMetadataItem.namePlural, + mapConnectionToRecords, + useRecordsWithoutConnection, + ], + ); + return { objectMetadataItem, - records: records as RecordType[], + records: useRecordsWithoutConnection ? recordsWithoutConnection : records, loading, error, fetchMoreRecords, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts new file mode 100644 index 0000000000..a682f1e2b6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts @@ -0,0 +1,113 @@ +import { useCallback } from 'react'; +import { isNonEmptyArray } from '@sniptt/guards'; +import { produce } from 'immer'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { FieldMetadataType } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const useMapConnectionToRecords = () => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const mapConnectionToRecords = useCallback( + ({ + objectRecordConnection, + objectNameSingular, + objectNamePlural, + depth, + }: { + objectRecordConnection: ObjectRecordConnection | undefined | null; + objectNameSingular?: string; + objectNamePlural?: string; + depth: number; + }): ObjectRecord[] => { + if ( + !isDefined(objectRecordConnection) || + !isNonEmptyArray(objectMetadataItems) + ) { + return []; + } + + const currentLevelObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === objectNameSingular || + objectMetadataItem.namePlural === objectNamePlural, + ); + + if (!currentLevelObjectMetadataItem) { + throw new Error( + `Could not find object metadata item for object name singular "${objectNameSingular}" in mapConnectionToRecords`, + ); + } + + const relationFields = currentLevelObjectMetadataItem.fields.filter( + (field) => field.type === FieldMetadataType.Relation, + ); + + const objectRecords = [ + ...(objectRecordConnection.edges?.map((edge) => edge.node) ?? []), + ]; + + return produce(objectRecords, (objectRecordsDraft) => { + for (const objectRecordDraft of objectRecordsDraft) { + for (const relationField of relationFields) { + const relationType = parseFieldRelationType(relationField); + + if ( + relationType === 'TO_ONE_OBJECT' || + relationType === 'FROM_ONE_OBJECT' + ) { + continue; + } + + const relatedObjectMetadataSingularName = + relationField.toRelationMetadata?.fromObjectMetadata + .nameSingular ?? + relationField.fromRelationMetadata?.toObjectMetadata + .nameSingular ?? + null; + + const relationFieldMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === + relatedObjectMetadataSingularName, + ); + + if ( + !relationFieldMetadataItem || + !isDefined(relatedObjectMetadataSingularName) + ) { + throw new Error( + `Could not find relation object metadata item for object name plural ${relationField.name} in mapConnectionToRecords`, + ); + } + + const relationConnection = objectRecordDraft?.[ + relationField.name + ] as ObjectRecordConnection | undefined | null; + + if (!isDefined(relationConnection)) { + continue; + } + + const relationConnectionMappedToRecords = mapConnectionToRecords({ + objectRecordConnection: relationConnection, + objectNameSingular: relatedObjectMetadataSingularName, + depth: depth - 1, + }); + + (objectRecordDraft as any)[relationField.name] = + relationConnectionMappedToRecords; + } + } + }) as ObjectRecord[]; + }, + [objectMetadataItems], + ); + + return mapConnectionToRecords; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts index 3dba09b274..f88566aa60 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoard.ts @@ -7,7 +7,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useRecordBoardScopedStates } from '@/object-record/record-board/hooks/internal/useRecordBoardScopedStates'; import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; -import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { Opportunity } from '@/pipeline/types/Opportunity'; import { PipelineStep } from '@/pipeline/types/PipelineStep'; @@ -57,7 +57,7 @@ export const useObjectRecordBoard = () => { objectNameSingular: CoreObjectNameSingular.PipelineStep, filter: {}, onCompleted: useCallback( - (data: PaginatedRecordTypeResults) => { + (data: ObjectRecordConnection) => { setSavedPipelineSteps(data.edges.map((edge) => edge.node)); }, [setSavedPipelineSteps], @@ -89,7 +89,7 @@ export const useObjectRecordBoard = () => { }, }, onCompleted: useCallback( - (data: PaginatedRecordTypeResults) => { + (data: ObjectRecordConnection) => { setSavedCompanies(data.edges.map((edge) => edge.node)); }, [setSavedCompanies], diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts index 62bfc91643..e8db81a3d3 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts @@ -1,11 +1,14 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; +import { Nullable } from '~/types/Nullable'; -export type ObjectRecordConnection = { - edges: ObjectRecordEdge[]; +export type ObjectRecordConnection = { + __typename?: string; + edges: ObjectRecordEdge[]; pageInfo: { hasNextPage?: boolean; hasPreviousPage?: boolean; - startCursor?: string; - endCursor?: string; + startCursor?: Nullable; + endCursor?: Nullable; }; }; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts index 76581297a7..51d815d41b 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordEdge.ts @@ -1,6 +1,7 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export type ObjectRecordEdge = { - node: ObjectRecord; +export type ObjectRecordEdge = { + __typename?: string; + node: T; cursor: string; }; diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryResult.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryResult.ts new file mode 100644 index 0000000000..ec828223b2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordQueryResult.ts @@ -0,0 +1,6 @@ +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; + +export type ObjectRecordQueryResult = { + [objectNamePlural: string]: ObjectRecordConnection; +}; diff --git a/packages/twenty-front/src/modules/object-record/types/PaginatedRecordType.ts b/packages/twenty-front/src/modules/object-record/types/PaginatedRecordType.ts deleted file mode 100644 index 07117f7348..0000000000 --- a/packages/twenty-front/src/modules/object-record/types/PaginatedRecordType.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PaginatedRecordTypeResults } from './PaginatedRecordTypeResults'; - -export type PaginatedRecordType = { - [objectNamePlural: string]: PaginatedRecordTypeResults; -}; diff --git a/packages/twenty-front/src/modules/object-record/types/PaginatedRecordTypeResults.ts b/packages/twenty-front/src/modules/object-record/types/PaginatedRecordTypeResults.ts deleted file mode 100644 index ec2c834532..0000000000 --- a/packages/twenty-front/src/modules/object-record/types/PaginatedRecordTypeResults.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type PaginatedRecordTypeEdge< - RecordType extends { id: string } & Record, -> = { - node: RecordType; - cursor: string; - __typename?: string; -}; - -export type PaginatedRecordTypeResults< - RecordType extends { id: string } & Record, -> = { - __typename?: string; - edges: PaginatedRecordTypeEdge[]; - pageInfo: { - hasNextPage: boolean; - startCursor: string; - endCursor: string; - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/utils/filterUniqueRecordEdgesByCursor.ts b/packages/twenty-front/src/modules/object-record/utils/filterUniqueRecordEdgesByCursor.ts index d9b2e5194a..048ea38f36 100644 --- a/packages/twenty-front/src/modules/object-record/utils/filterUniqueRecordEdgesByCursor.ts +++ b/packages/twenty-front/src/modules/object-record/utils/filterUniqueRecordEdgesByCursor.ts @@ -1,9 +1,9 @@ -import { PaginatedRecordTypeEdge } from '@/object-record/types/PaginatedRecordTypeResults'; +import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; export const filterUniqueRecordEdgesByCursor = < RecordType extends { id: string }, >( - arrayToFilter: PaginatedRecordTypeEdge[], + arrayToFilter: ObjectRecordEdge[], ) => { const seenCursors = new Set(); diff --git a/packages/twenty-front/src/modules/views/components/ViewBarEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarEffect.tsx index 37776bca75..880273e339 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarEffect.tsx @@ -41,6 +41,7 @@ export const ViewBarEffect = () => { type: { eq: viewType }, objectMetadataId: { eq: viewObjectMetadataId }, }, + useRecordsWithoutConnection: true, }); useEffect(() => { diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar.test.tsx index 54f0d60fc0..d028640256 100644 --- a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar.test.tsx +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar.test.tsx @@ -284,33 +284,11 @@ describe('useViewBar', () => { viewBar.setAvailableSortDefinitions([sortDefinition]); - viewBar.loadViewSorts( - { - edges: [ - { - node: viewSort, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - mockedUuid, - ); + viewBar.loadViewSorts([viewSort], mockedUuid); viewBar.setAvailableFilterDefinitions([filterDefinition]); - viewBar.loadViewFilters( - { - edges: [ - { - node: viewFilter, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - mockedUuid, - ); + viewBar.loadViewFilters([viewFilter], mockedUuid); return { viewBar }; }, renderHookConfig); diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFields.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFields.test.tsx index be182cb011..5820ea5ad5 100644 --- a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFields.test.tsx +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFields.test.tsx @@ -159,18 +159,7 @@ describe('useViewBar > viewFields', () => { await act(async () => { result.current.viewBar.setAvailableFieldDefinitions([fieldDefinition]); - await result.current.viewBar.loadViewFields( - { - edges: [ - { - node: viewField, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + await result.current.viewBar.loadViewFields([viewField], currentViewId); result.current.viewBar.setCurrentViewId(currentViewId); }); diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFilters.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFilters.test.tsx index 6e6e0eb324..442e9a8722 100644 --- a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFilters.test.tsx +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewFilters.test.tsx @@ -69,18 +69,7 @@ describe('useViewBar > viewFilters', () => { await act(async () => { result.current.viewBar.setAvailableFilterDefinitions([filterDefinition]); - await result.current.viewBar.loadViewFilters( - { - edges: [ - { - node: viewFilter, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + await result.current.viewBar.loadViewFilters([viewFilter], currentViewId); result.current.viewBar.setCurrentViewId(currentViewId); }); @@ -93,18 +82,7 @@ describe('useViewBar > viewFilters', () => { viewBar.setAvailableFilterDefinitions([filterDefinition]); - viewBar.loadViewFilters( - { - edges: [ - { - node: viewFilter, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + viewBar.loadViewFilters([viewFilter], currentViewId); viewBar.setCurrentViewId(currentViewId); const { currentViewFiltersState } = useViewScopedStates({ @@ -174,18 +152,7 @@ describe('useViewBar > viewFilters', () => { viewBar.setAvailableFilterDefinitions([filterDefinition]); - viewBar.loadViewFilters( - { - edges: [ - { - node: viewFilter, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + viewBar.loadViewFilters([viewFilter], currentViewId); viewBar.setCurrentViewId(currentViewId); const { currentViewFiltersState } = useViewScopedStates({ diff --git a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewSorts.test.tsx b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewSorts.test.tsx index e76422240c..b1a9c4d7a3 100644 --- a/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewSorts.test.tsx +++ b/packages/twenty-front/src/modules/views/hooks/__tests__/useViewBar_ViewSorts.test.tsx @@ -65,18 +65,7 @@ describe('View Sorts', () => { await act(async () => { result.current.viewBar.setAvailableSortDefinitions([sortDefinition]); - await result.current.viewBar.loadViewSorts( - { - edges: [ - { - node: viewSort, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + await result.current.viewBar.loadViewSorts([viewSort], currentViewId); result.current.viewBar.setCurrentViewId(currentViewId); }); @@ -89,18 +78,7 @@ describe('View Sorts', () => { viewBar.setAvailableSortDefinitions([sortDefinition]); - viewBar.loadViewSorts( - { - edges: [ - { - node: viewSort, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + viewBar.loadViewSorts([viewSort], currentViewId); viewBar.setCurrentViewId(currentViewId); const { currentViewSortsState } = useViewScopedStates({ @@ -161,18 +139,7 @@ describe('View Sorts', () => { viewBar.setAvailableSortDefinitions([sortDefinition]); - viewBar.loadViewSorts( - { - edges: [ - { - node: viewSort, - cursor: '', - }, - ], - pageInfo: { hasNextPage: false, startCursor: '', endCursor: '' }, - }, - currentViewId, - ); + viewBar.loadViewSorts([viewSort], currentViewId); viewBar.setCurrentViewId(currentViewId); const { currentViewSortsState } = useViewScopedStates({ diff --git a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts index 77d135d37d..ea2bd6d1d0 100644 --- a/packages/twenty-front/src/modules/views/hooks/useViewBar.ts +++ b/packages/twenty-front/src/modules/views/hooks/useViewBar.ts @@ -3,7 +3,6 @@ import { useSearchParams } from 'react-router-dom'; import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import { v4 } from 'uuid'; -import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { ViewField } from '@/views/types/ViewField'; import { ViewFilter } from '@/views/types/ViewFilter'; @@ -93,10 +92,7 @@ export const useViewBar = (props?: UseViewProps) => { const loadViewFields = useRecoilCallback( ({ snapshot, set }) => - async ( - data: PaginatedRecordTypeResults, - currentViewId: string, - ) => { + async (viewFields: ViewField[], currentViewId: string) => { const { availableFieldDefinitions, onViewFieldsChange, @@ -119,8 +115,8 @@ export const useViewBar = (props?: UseViewProps) => { return; } - const queriedViewFields = data.edges - .map((viewField) => viewField.node) + const queriedViewFields = viewFields + .map((viewField) => viewField) .filter(assertNotNull); if (isPersistingView) { @@ -138,10 +134,7 @@ export const useViewBar = (props?: UseViewProps) => { const loadViewFilters = useRecoilCallback( ({ snapshot, set }) => - async ( - data: PaginatedRecordTypeResults>, - currentViewId: string, - ) => { + async (viewFilters: ViewFilter[], currentViewId: string) => { const { availableFilterDefinitions, savedViewFilters, @@ -163,18 +156,18 @@ export const useViewBar = (props?: UseViewProps) => { return; } - const queriedViewFilters = data.edges - .map(({ node }) => { + const queriedViewFilters = viewFilters + .map((viewFilter) => { const availableFilterDefinition = availableFilterDefinitions.find( (filterDefinition) => - filterDefinition.fieldMetadataId === node.fieldMetadataId, + filterDefinition.fieldMetadataId === viewFilter.fieldMetadataId, ); if (!availableFilterDefinition) return null; return { - ...node, - displayValue: node.displayValue ?? node.value, + ...viewFilter, + displayValue: viewFilter.displayValue ?? viewFilter.value, definition: availableFilterDefinition, }; }) @@ -191,10 +184,7 @@ export const useViewBar = (props?: UseViewProps) => { const loadViewSorts = useRecoilCallback( ({ snapshot, set }) => - async ( - data: PaginatedRecordTypeResults>, - currentViewId: string, - ) => { + async (viewSorts: Required[], currentViewId: string) => { const { availableSortDefinitions, savedViewSorts, onViewSortsChange } = getViewScopedStateValuesFromSnapshot({ snapshot, @@ -213,18 +203,18 @@ export const useViewBar = (props?: UseViewProps) => { return; } - const queriedViewSorts = data.edges - .map(({ node }) => { + const queriedViewSorts = viewSorts + .map((viewSort) => { const availableSortDefinition = availableSortDefinitions.find( - (sort) => sort.fieldMetadataId === node.fieldMetadataId, + (sort) => sort.fieldMetadataId === viewSort.fieldMetadataId, ); if (!availableSortDefinition) return null; return { - id: node.id, - fieldMetadataId: node.fieldMetadataId, - direction: node.direction, + id: viewSort.id, + fieldMetadataId: viewSort.fieldMetadataId, + direction: viewSort.direction, definition: availableSortDefinition, }; }) diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index aa54c530c1..4839989e6f 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -1,4 +1,3 @@ -import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; import { ViewField } from '@/views/types/ViewField'; import { ViewFilter } from '@/views/types/ViewFilter'; import { ViewSort } from '@/views/types/ViewSort'; @@ -7,7 +6,7 @@ export type GraphQLView = { id: string; name: string; objectMetadataId: string; - viewFields: PaginatedRecordTypeResults; - viewFilters: PaginatedRecordTypeResults; - viewSorts: PaginatedRecordTypeResults; + viewFields: ViewField[]; + viewFilters: ViewFilter[]; + viewSorts: ViewSort[]; }; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index 7d9a772149..e00f115fbd 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -7,7 +7,7 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { PaginatedRecordTypeResults } from '@/object-record/types/PaginatedRecordTypeResults'; +import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons'; import { SettingsHeaderContainer } from '@/settings/components/SettingsHeaderContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; @@ -93,7 +93,7 @@ export const SettingsObjectNewFieldStep2 = () => { type: { eq: ViewType.Table }, objectMetadataId: { eq: activeObjectMetadataItem?.id }, }, - onCompleted: async (data: PaginatedRecordTypeResults) => { + onCompleted: async (data: ObjectRecordConnection) => { const views = data.edges; if (!views) return; @@ -109,7 +109,7 @@ export const SettingsObjectNewFieldStep2 = () => { type: { eq: ViewType.Table }, objectMetadataId: { eq: formValues.relation?.objectMetadataId }, }, - onCompleted: async (data: PaginatedRecordTypeResults) => { + onCompleted: async (data: ObjectRecordConnection) => { const views = data.edges; if (!views) return; diff --git a/packages/twenty-front/src/testing/jest/getJestHookWrapper.tsx b/packages/twenty-front/src/testing/jest/getJestHookWrapper.tsx new file mode 100644 index 0000000000..d16f71bebb --- /dev/null +++ b/packages/twenty-front/src/testing/jest/getJestHookWrapper.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { MutableSnapshot, RecoilRoot } from 'recoil'; + +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; + +export const getJestHookWrapper = ({ + apolloMocks, + onInitializeRecoilSnapshot, +}: { + apolloMocks: + | readonly MockedResponse, Record>[] + | undefined; + onInitializeRecoilSnapshot?: (snapshot: MutableSnapshot) => void; +}) => { + return ({ children }: { children: ReactNode }) => ( + + + + {children} + + + + ); +}; diff --git a/yarn.lock b/yarn.lock index 7618423189..b353f28c2c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -35183,7 +35183,7 @@ __metadata: languageName: node linkType: hard -"playwright@npm:^1.14.0": +"playwright@npm:^1.14.0, playwright@npm:^1.40.1": version: 1.40.1 resolution: "playwright@npm:1.40.1" dependencies: @@ -41675,6 +41675,7 @@ __metadata: patch-package: "npm:^8.0.0" pg: "npm:^8.11.3" pg-boss: "npm:^9.0.3" + playwright: "npm:^1.40.1" prettier: "npm:^3.1.1" prism-react-renderer: "npm:^2.1.0" raw-loader: "npm:^4.0.2"