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';