diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index fe66ea058..29e15b03a 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -125,6 +125,7 @@ export const RecordIndexOptionsDropdownContent = ({ filename: `${objectNameSingular}.csv`, objectNameSingular, recordIndexId, + viewType, }); const location = useLocation(); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useTableData.test.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useTableData.test.tsx new file mode 100644 index 000000000..3d0ce75a0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useTableData.test.tsx @@ -0,0 +1,379 @@ +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { percentage, sleep, useTableData } from '../useTableData'; + +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { recordBoardKanbanFieldMetadataNameComponentState } from '@/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState'; +import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { ViewType } from '@/views/types/ViewType'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import gql from 'graphql-tag'; +import { ReactNode } from 'react'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +const defaultResponseData = { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + totalCount: 1, +}; +const mockPerson = { + __typename: 'Person', + updatedAt: '2021-08-03T19:20:06.000Z', + myCustomObjectId: '123', + whatsapp: '123', + linkedinLink: { + primaryLinkUrl: 'https://www.linkedin.com', + primaryLinkLabel: 'linkedin', + secondaryLinks: ['https://www.linkedin.com'], + }, + name: { + firstName: 'firstName', + lastName: 'lastName', + }, + email: 'email', + position: 'position', + createdBy: { + source: 'source', + workspaceMemberId: '1', + name: 'name', + }, + avatarUrl: 'avatarUrl', + jobTitle: 'jobTitle', + xLink: { + primaryLinkUrl: 'https://www.linkedin.com', + primaryLinkLabel: 'linkedin', + secondaryLinks: ['https://www.linkedin.com'], + }, + performanceRating: 1, + createdAt: '2021-08-03T19:20:06.000Z', + phone: 'phone', + id: '123', + city: 'city', + companyId: '1', + intro: 'intro', + workPrefereance: 'workPrefereance', +}; +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + query FindManyPeople( + $filter: PersonFilterInput + $orderBy: [PersonOrderByInput] + $lastCursor: String + $limit: Int + ) { + people( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { + edges { + node { + __typename + updatedAt + myCustomObjectId + whatsapp + linkedinLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + name { + firstName + lastName + } + email + position + createdBy { + source + workspaceMemberId + name + } + avatarUrl + jobTitle + xLink { + primaryLinkUrl + primaryLinkLabel + secondaryLinks + } + performanceRating + createdAt + phone + id + city + companyId + intro + workPrefereance + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + `, + variables: { + filter: undefined, + limit: 30, + orderBy: [{ position: 'AscNullsFirst' }], + }, + }, + result: jest.fn(() => ({ + data: { + people: { + ...defaultResponseData, + edges: [ + { + node: mockPerson, + cursor: '1', + }, + ], + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + + {children} + + + + +); + +const graphqlEmptyResponse = [ + { + ...mocks[0], + result: jest.fn(() => ({ + data: { + people: { + ...defaultResponseData, + edges: [], + }, + }, + })), + }, +]; + +const WrapperWithEmptyResponse = ({ children }: { children: ReactNode }) => ( + + + + + {children} + + + + +); + +describe('useTableData', () => { + const recordIndexId = 'people'; + const objectNameSingular = 'person'; + describe('data fetching', () => { + it('should handle no records', async () => { + const callback = jest.fn(); + const { result } = renderHook( + () => + useTableData({ + recordIndexId, + objectNameSingular, + callback, + + delayMs: 0, + viewType: ViewType.Kanban, + }), + { wrapper: WrapperWithEmptyResponse }, + ); + + await act(async () => { + result.current.getTableData(); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith([], []); + }); + }); + + it('should call the callback function with fetched data', async () => { + const callback = jest.fn(); + const { result } = renderHook( + () => + useTableData({ + recordIndexId, + objectNameSingular, + callback, + + delayMs: 0, + }), + { wrapper: Wrapper }, + ); + + await act(async () => { + result.current.getTableData(); + }); + + await waitFor(() => { + expect(callback).toHaveBeenCalledWith([mockPerson], []); + }); + }); + + it('should call the callback function with kanban field included as column if view type is kanban', async () => { + const callback = jest.fn(); + const { result } = renderHook( + () => { + const kanbanFieldNameState = extractComponentState( + recordBoardKanbanFieldMetadataNameComponentState, + recordIndexId, + ); + return { + tableData: useTableData({ + recordIndexId, + objectNameSingular, + callback, + pageSize: 30, + maximumRequests: 100, + delayMs: 0, + viewType: ViewType.Kanban, + }), + setKanbanFieldName: useRecordBoard(recordIndexId), + kanbanFieldName: useRecoilValue(kanbanFieldNameState), + kanbanData: useRecordIndexOptionsForBoard({ + objectNameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }), + }; + }, + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + result.current.setKanbanFieldName.setKanbanFieldMetadataName( + result.current.kanbanData.hiddenBoardFields[0].metadata.fieldName, + ); + }); + + await act(async () => { + result.current.tableData.getTableData(); + }); + + await waitFor(async () => { + expect(callback).toHaveBeenCalledWith( + [mockPerson], + [ + { + defaultValue: 'now', + editButtonIcon: undefined, + fieldMetadataId: '102963b7-3e77-4293-a1e6-1ab59a02b663', + iconName: 'IconCalendarClock', + isFilterable: true, + isLabelIdentifier: false, + isSortable: true, + isVisible: false, + label: 'Last update', + labelWidth: undefined, + metadata: { + fieldName: 'updatedAt', + isNullable: false, + objectMetadataNameSingular: 'person', + options: null, + placeHolder: 'Last update', + relationFieldMetadataId: undefined, + relationObjectMetadataNamePlural: '', + relationObjectMetadataNameSingular: '', + relationType: undefined, + targetFieldMetadataName: '', + }, + position: 0, + showLabel: undefined, + size: 100, + type: 'DATE_TIME', + }, + ], + ); + }); + }); + + it('should not call the callback function with kanban field included as column if view type is table', async () => { + const callback = jest.fn(); + const { result } = renderHook( + () => { + const kanbanFieldNameState = extractComponentState( + recordBoardKanbanFieldMetadataNameComponentState, + recordIndexId, + ); + return { + tableData: useTableData({ + recordIndexId, + objectNameSingular, + callback, + pageSize: 30, + maximumRequests: 100, + delayMs: 0, + viewType: ViewType.Table, + }), + setKanbanFieldName: useRecordBoard(recordIndexId), + kanbanFieldName: useRecoilValue(kanbanFieldNameState), + kanbanData: useRecordIndexOptionsForBoard({ + objectNameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }), + }; + }, + { + wrapper: Wrapper, + }, + ); + + await act(async () => { + result.current.setKanbanFieldName.setKanbanFieldMetadataName( + result.current.kanbanData.hiddenBoardFields[0].metadata.fieldName, + ); + }); + + await act(async () => { + result.current.tableData.getTableData(); + }); + + await waitFor(async () => { + expect(callback).toHaveBeenCalledWith([mockPerson], []); + }); + }); + }); + + describe('utils', () => { + it('should correctly calculate percentage', () => { + expect(percentage(50, 200)).toBe(25); + expect(percentage(1, 3)).toBe(33); + }); + + it('should resolve sleep after given time', async () => { + jest.useFakeTimers(); + const sleepPromise = sleep(1000); + jest.advanceTimersByTime(1000); + await expect(sleepPromise).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts index e62a90ea5..d6f6f83f3 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts @@ -143,6 +143,7 @@ export const useExportTableData = ({ objectNameSingular, pageSize = 30, recordIndexId, + viewType, }: UseExportTableDataOptions) => { const { processRecordsForCSVExport } = useProcessRecordsForCSVExport(objectNameSingular); @@ -164,6 +165,7 @@ export const useExportTableData = ({ pageSize, recordIndexId, callback: downloadCsv, + viewType, }); return { progress, download }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts index b389d906d..46572dffe 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useTableData.ts @@ -8,6 +8,9 @@ import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefin import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isDefined } from '~/utils/isDefined'; +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; +import { ViewType } from '@/views/types/ViewType'; import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable'; export const sleep = (ms: number) => @@ -27,6 +30,7 @@ export type UseTableDataOptions = { rows: ObjectRecord[], columns: ColumnDefinition[], ) => void | Promise; + viewType?: ViewType; }; type ExportProgress = { @@ -42,6 +46,7 @@ export const useTableData = ({ pageSize = 30, recordIndexId, callback, + viewType = ViewType.Table, }: UseTableDataOptions) => { const [isDownloading, setIsDownloading] = useState(false); const [inflight, setInflight] = useState(false); @@ -58,6 +63,17 @@ export const useTableData = ({ hasUserSelectedAllRowsState, } = useRecordTableStates(recordIndexId); + const { hiddenBoardFields } = useRecordIndexOptionsForBoard({ + objectNameSingular, + recordBoardId: recordIndexId, + viewBarId: recordIndexId, + }); + + const { kanbanFieldMetadataNameState } = useRecordBoardStates(recordIndexId); + const kanbanFieldMetadataName = useRecoilValue(kanbanFieldMetadataNameState); + const hiddenKanbanFieldColumn = hiddenBoardFields.find( + (column) => column.metadata.fieldName === kanbanFieldMetadataName, + ); const columns = useRecoilValue(visibleTableColumnsSelector()); const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); @@ -165,7 +181,14 @@ export const useTableData = ({ }); }; - const res = callback(records, columns); + const finalColumns = [ + ...columns, + ...(hiddenKanbanFieldColumn && viewType === ViewType.Kanban + ? [hiddenKanbanFieldColumn] + : []), + ]; + + const res = callback(records, finalColumns); if (res instanceof Promise) { res.then(complete); @@ -189,6 +212,8 @@ export const useTableData = ({ loading, callback, previousRecordCount, + hiddenKanbanFieldColumn, + viewType, ]); return { diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index a14ea35bf..656dcbb80 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -1,9 +1,10 @@ -import { CaptchaDriverType } from '~/generated/graphql'; import { ClientConfig } from '~/generated-metadata/graphql'; +import { CaptchaDriverType } from '~/generated/graphql'; export const mockedClientConfig: ClientConfig = { signInPrefilled: true, signUpDisabled: false, + chromeExtensionId: 'MOCKED_EXTENSION_ID', debugMode: false, authProviders: { google: true, diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 2bbe2185f..5d6514c42 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -6,6 +6,8 @@ import { User, Workspace, WorkspaceActivationStatus, + WorkspaceMemberDateFormatEnum, + WorkspaceMemberTimeFormatEnum, } from '~/generated/graphql'; type MockedUser = Pick< @@ -85,6 +87,9 @@ export const mockedWorkspaceMemberData: WorkspaceMember = { updatedAt: '2023-04-26T10:23:42.33625+00:00', userId: '2603c1f9-0172-4ea6-986c-eeaccdf7f4cf', userEmail: 'charles@test.com', + dateFormat: WorkspaceMemberDateFormatEnum.DayFirst, + timeFormat: WorkspaceMemberTimeFormatEnum.Hour_24, + timeZone: 'America/New_York', }; export const mockedUserData: MockedUser = { diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts index c98949d2a..d0dc9a5cb 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.ts @@ -18,9 +18,9 @@ import { SaveOptions, UpdateResult, } from 'typeorm'; -import { PickKeysByType } from 'typeorm/common/PickKeysByType'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; import { UpsertOptions } from 'typeorm/repository/UpsertOptions'; +import { PickKeysByType } from 'typeorm/common/PickKeysByType'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';