From 5586270df4d4f03bed8bf2ff3137a473196a8210 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 18 Dec 2024 18:05:33 +0100 Subject: [PATCH 01/12] Remove buggy dependencies (#9115) Fixes double download --- .../hooks/useDeleteMultipleRecordsAction.tsx | 10 +- .../__tests__/useFetchAllRecordIds.test.tsx | 96 ---------- .../__tests__/useLazyFetchAllRecords.test.tsx | 173 ++++++++++++++++++ .../hooks/useFetchAllRecordIds.ts | 88 --------- .../hooks/useLazyFetchAllRecords.ts | 141 ++++++++++++++ .../__tests__/useExportFetchRecords.test.ts | 124 +++---------- .../export/hooks/useExportFetchRecords.ts | 120 +++--------- 7 files changed, 369 insertions(+), 383 deletions(-) delete mode 100644 packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts create mode 100644 packages/twenty-front/src/modules/object-record/hooks/useLazyFetchAllRecords.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx index 5e517ace1346..7e2c5a405c30 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction.tsx @@ -13,13 +13,14 @@ import { useFavorites } from '@/favorites/hooks/useFavorites'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; -import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useCallback, useContext, useState } from 'react'; import { IconTrash, isDefined } from 'twenty-ui'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; export const useDeleteMultipleRecordsAction = ({ objectMetadataItem, @@ -60,15 +61,18 @@ export const useDeleteMultipleRecordsAction = ({ objectMetadataItem, ); - const { fetchAllRecordIds } = useFetchAllRecordIds({ + const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({ objectNameSingular: objectMetadataItem.nameSingular, filter: graphqlFilter, + limit: DEFAULT_QUERY_PAGE_SIZE, + recordGqlFields: { id: true }, }); const { closeRightDrawer } = useRightDrawer(); const handleDeleteClick = useCallback(async () => { - const recordIdsToDelete = await fetchAllRecordIds(); + const recordsToDelete = await fetchAllRecordIds(); + const recordIdsToDelete = recordsToDelete.map((record) => record.id); resetTableRowSelection(); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx deleted file mode 100644 index 80b57d7dc5bd..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFetchAllRecordIds.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { useEffect } from 'react'; -import { useRecoilState } from 'recoil'; - -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { - mockPageSize, - peopleMockWithIdsOnly, - query, - responseFirstRequest, - responseSecondRequest, - responseThirdRequest, - variablesFirstRequest, - variablesSecondRequest, - variablesThirdRequest, -} from '@/object-record/hooks/__mocks__/useFetchAllRecordIds'; -import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds'; -import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; -import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; - -const mocks = [ - { - delay: 100, - request: { - query, - variables: variablesFirstRequest, - }, - result: jest.fn(() => ({ - data: responseFirstRequest, - })), - }, - { - delay: 100, - request: { - query, - variables: variablesSecondRequest, - }, - result: jest.fn(() => ({ - data: responseSecondRequest, - })), - }, - { - delay: 100, - request: { - query, - variables: variablesThirdRequest, - }, - result: jest.fn(() => ({ - data: responseThirdRequest, - })), - }, -]; - -const Wrapper = getJestMetadataAndApolloMocksWrapper({ - apolloMocks: mocks, -}); - -describe('useFetchAllRecordIds', () => { - it('fetches all record ids with fetch more synchronous loop', async () => { - const { result } = renderHook( - () => { - const [, setObjectMetadataItems] = useRecoilState( - objectMetadataItemsState, - ); - - useEffect(() => { - setObjectMetadataItems(generatedMockObjectMetadataItems); - }, [setObjectMetadataItems]); - - return useFetchAllRecordIds({ - objectNameSingular: 'person', - pageSize: mockPageSize, - }); - }, - { - wrapper: Wrapper, - }, - ); - - const { fetchAllRecordIds } = result.current; - - let recordIds: string[] = []; - - await act(async () => { - recordIds = await fetchAllRecordIds(); - }); - - expect(mocks[0].result).toHaveBeenCalled(); - expect(mocks[1].result).toHaveBeenCalled(); - expect(mocks[2].result).toHaveBeenCalled(); - - expect(recordIds).toEqual( - peopleMockWithIdsOnly.edges.map((edge) => edge.node.id).slice(0, 6), - ); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx new file mode 100644 index 000000000000..c7d28f10b373 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useLazyFetchAllRecords.test.tsx @@ -0,0 +1,173 @@ +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { expect } from '@storybook/test'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; +import { MockedResponse } from '@apollo/client/testing'; +import gql from 'graphql-tag'; +import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments'; +import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper'; + +const defaultResponseData = { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + totalCount: 2, +}; + +const mockPerson = { + __typename: 'Person', + updatedAt: '2021-08-03T19:20:06.000Z', + whatsapp: { + primaryPhoneNumber: '+1', + primaryPhoneCountryCode: '234-567-890', + additionalPhones: [], + }, + linkedinLink: { + primaryLinkUrl: 'https://www.linkedin.com', + primaryLinkLabel: 'linkedin', + secondaryLinks: ['https://www.linkedin.com'], + }, + name: { + firstName: 'firstName', + lastName: 'lastName', + }, + emails: { + primaryEmail: 'email', + additionalEmails: [], + }, + 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', + phones: { + primaryPhoneNumber: '+1', + primaryPhoneCountryCode: '234-567-890', + additionalPhones: [], + }, + id: '123', + city: 'city', + companyId: '1', + intro: 'intro', + deletedAt: null, + workPreference: 'workPreference', +}; + +const mock: 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 { + ${PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS} + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + } + } + `, + variables: { + limit: 30, + }, + }, + result: jest.fn(() => ({ + data: { + people: { + ...defaultResponseData, + edges: [ + { + node: mockPerson, + cursor: '1', + }, + { + node: mockPerson, + cursor: '2', + }, + ], + }, + }, + })), +}; + +const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [mock], + componentInstanceId: 'recordIndexId', + contextStoreTargetedRecordsRule: { + mode: 'selection', + selectedRecordIds: [], + }, + contextStoreCurrentObjectMetadataNameSingular: 'person', +}); + +describe('useLazyFetchAllRecords', () => { + const objectNameSingular = 'person'; + const objectMetadataItem = generatedMockObjectMetadataItems.find( + (item) => item.nameSingular === objectNameSingular, + ); + if (!objectMetadataItem) { + throw new Error('Object metadata item not found'); + } + + it('should handle one single page', async () => { + const { result } = renderHook( + () => + useLazyFetchAllRecords({ + objectNameSingular, + limit: 30, + }), + { + wrapper: Wrapper, + }, + ); + + let res: any; + + act(() => { + res = result.current.fetchAllRecords(); + }); + + expect(result.current.isDownloading).toBe(true); + + await waitFor(() => { + expect(result.current.isDownloading).toBe(false); + expect(result.current.progress).toEqual({ displayType: 'number' }); + }); + + expect(result.current.progress).toEqual({ displayType: 'number' }); + + const finalResult = await res; + + expect(finalResult).toEqual([mockPerson, mockPerson]); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts deleted file mode 100644 index 715cdc5af15d..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useFetchAllRecordIds.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; -import { UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination'; -import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; -import { useCallback } from 'react'; -import { isDefined } from '~/utils/isDefined'; - -type UseLazyFetchAllRecordIdsParams = Omit< - UseFindManyRecordsParams, - 'skip' -> & { pageSize?: number }; - -export const useFetchAllRecordIds = ({ - objectNameSingular, - filter, - orderBy, - pageSize = DEFAULT_QUERY_PAGE_SIZE, -}: UseLazyFetchAllRecordIdsParams) => { - const { fetchMore, findManyRecords } = useLazyFindManyRecords({ - objectNameSingular, - filter, - orderBy, - limit: pageSize, - recordGqlFields: { id: true }, - }); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const fetchAllRecordIds = useCallback(async () => { - if (!isDefined(findManyRecords)) { - return []; - } - - const findManyRecordsDataResult = await findManyRecords(); - - const firstQueryResult = - findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural]; - - const totalCount = firstQueryResult?.totalCount ?? 0; - - const recordsCount = firstQueryResult?.edges.length ?? 0; - - const recordIdSet = new Set( - firstQueryResult?.edges?.map((edge) => edge.node.id) ?? [], - ); - - const remainingCount = totalCount - recordsCount; - - const remainingPages = Math.ceil(remainingCount / pageSize); - - let lastCursor = firstQueryResult?.pageInfo.endCursor ?? null; - - for (let pageIndex = 0; pageIndex < remainingPages; pageIndex++) { - if (lastCursor === null) { - break; - } - - const rawResult = await fetchMore?.({ - variables: { - lastCursor: lastCursor, - limit: pageSize, - }, - }); - - const fetchMoreResult = rawResult?.data?.[objectMetadataItem.namePlural]; - - for (const edge of fetchMoreResult.edges) { - recordIdSet.add(edge.node.id); - } - - if (fetchMoreResult.pageInfo.hasNextPage === false) { - break; - } - - lastCursor = fetchMoreResult.pageInfo.endCursor ?? null; - } - - const recordIds = Array.from(recordIdSet); - - return recordIds; - }, [fetchMore, findManyRecords, objectMetadataItem.namePlural, pageSize]); - - return { - fetchAllRecordIds, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchAllRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchAllRecords.ts new file mode 100644 index 000000000000..1bc1c3f5d94b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFetchAllRecords.ts @@ -0,0 +1,141 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination'; +import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; +import { useCallback, useState } from 'react'; +import { isDefined } from '~/utils/isDefined'; +import { sleep } from '~/utils/sleep'; +import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize'; + +type UseLazyFetchAllRecordIdsParams = Omit< + UseFindManyRecordsParams, + 'skip' +> & { + pageSize?: number; + delayMs?: number; + maximumRequests?: number; +}; + +type ExportProgress = { + exportedRecordCount?: number; + totalRecordCount?: number; + displayType: 'percentage' | 'number'; +}; + +export const useLazyFetchAllRecords = ({ + objectNameSingular, + filter, + orderBy, + limit = DEFAULT_QUERY_PAGE_SIZE, + delayMs = 0, + maximumRequests = 100, + recordGqlFields, +}: UseLazyFetchAllRecordIdsParams) => { + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState({ + displayType: 'number', + }); + const { fetchMore, findManyRecords } = useLazyFindManyRecords({ + objectNameSingular, + filter, + orderBy, + limit, + recordGqlFields, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const fetchAllRecords = useCallback(async () => { + if (!isDefined(findManyRecords)) { + return []; + } + setIsDownloading(true); + + const findManyRecordsDataResult = await findManyRecords(); + + const firstQueryResult = + findManyRecordsDataResult?.data?.[objectMetadataItem.namePlural]; + + const totalCount = firstQueryResult?.totalCount ?? 0; + + const recordsCount = firstQueryResult?.edges.length ?? 0; + + const records = firstQueryResult?.edges?.map((edge) => edge.node) ?? []; + + setProgress({ + exportedRecordCount: recordsCount, + totalRecordCount: totalCount, + displayType: totalCount ? 'percentage' : 'number', + }); + + const remainingCount = totalCount - recordsCount; + + const remainingPages = Math.ceil(remainingCount / limit); + + let lastCursor = firstQueryResult?.pageInfo.endCursor ?? null; + + for ( + let pageIndex = 0; + pageIndex < Math.min(maximumRequests, remainingPages); + pageIndex++ + ) { + if (lastCursor === null) { + break; + } + + if (!isDefined(fetchMore)) { + break; + } + + if (delayMs > 0) { + await sleep(delayMs); + } + + const rawResult = await fetchMore({ + variables: { + lastCursor: lastCursor, + limit, + }, + }); + + const fetchMoreResult = rawResult?.data?.[objectMetadataItem.namePlural]; + + for (const edge of fetchMoreResult.edges) { + records.push(edge.node); + } + + setProgress({ + exportedRecordCount: records.length, + totalRecordCount: totalCount, + displayType: totalCount ? 'percentage' : 'number', + }); + + if (fetchMoreResult.pageInfo.hasNextPage === false) { + break; + } + + lastCursor = fetchMoreResult.pageInfo.endCursor ?? null; + } + + setIsDownloading(false); + setProgress({ + displayType: 'number', + }); + + return records; + }, [ + delayMs, + fetchMore, + findManyRecords, + objectMetadataItem.namePlural, + limit, + maximumRequests, + ]); + + return { + progress, + isDownloading, + fetchAllRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts index 6621a234be8f..21e9775c0d9f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/__tests__/useExportFetchRecords.test.ts @@ -6,26 +6,14 @@ import { useExportFetchRecords, } from '../useExportFetchRecords'; -import { PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS } from '@/object-record/hooks/__mocks__/personFragments'; import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { ViewType } from '@/views/types/ViewType'; -import { MockedResponse } from '@apollo/client/testing'; import { expect } from '@storybook/test'; -import gql from 'graphql-tag'; import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndContextStoreWrapper'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; - -const defaultResponseData = { - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - endCursor: '', - }, - totalCount: 1, -}; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; const mockPerson = { __typename: 'Person', @@ -76,62 +64,8 @@ const mockPerson = { workPreference: 'workPreference', }; -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 { - ${PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS} - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `, - variables: { - filter: {}, - limit: 30, - orderBy: [{ position: 'AscNullsFirst' }], - }, - }, - result: jest.fn(() => ({ - data: { - people: { - ...defaultResponseData, - edges: [ - { - node: mockPerson, - cursor: '1', - }, - ], - }, - }, - })), - }, -]; - -const WrapperWithResponse = getJestMetadataAndApolloMocksAndActionMenuWrapper({ - apolloMocks: mocks, +const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({ + apolloMocks: [], componentInstanceId: 'recordIndexId', contextStoreTargetedRecordsRule: { mode: 'selection', @@ -140,43 +74,36 @@ const WrapperWithResponse = getJestMetadataAndApolloMocksAndActionMenuWrapper({ contextStoreCurrentObjectMetadataNameSingular: 'person', }); -const graphqlEmptyResponse = [ - { - ...mocks[0], - result: jest.fn(() => ({ - data: { - people: { - ...defaultResponseData, - edges: [], - }, - }, - })), - }, -]; - -const WrapperWithEmptyResponse = - getJestMetadataAndApolloMocksAndActionMenuWrapper({ - apolloMocks: graphqlEmptyResponse, - componentInstanceId: 'recordIndexId', - contextStoreTargetedRecordsRule: { - mode: 'selection', - selectedRecordIds: [], - }, - contextStoreCurrentObjectMetadataNameSingular: 'person', - }); +jest.mock('@/object-record/hooks/useLazyFetchAllRecords', () => ({ + useLazyFetchAllRecords: jest.fn(), +})); describe('useRecordData', () => { const recordIndexId = 'people'; const objectMetadataItem = generatedMockObjectMetadataItems.find( (item) => item.nameSingular === 'person', ); + let mockFetchAllRecords: jest.Mock; + + beforeEach(() => { + // Mock the hook's implementation + mockFetchAllRecords = jest.fn(); + (useLazyFetchAllRecords as jest.Mock).mockReturnValue({ + progress: 100, + isDownloading: false, + fetchAllRecords: mockFetchAllRecords, // Mock the function + }); + }); if (!objectMetadataItem) { throw new Error('Object metadata item not found'); } + describe('data fetching', () => { it('should handle no records', async () => { const callback = jest.fn(); + mockFetchAllRecords.mockReturnValue([]); + const { result } = renderHook( () => useExportFetchRecords({ @@ -188,7 +115,7 @@ describe('useRecordData', () => { viewType: ViewType.Kanban, }), { - wrapper: WrapperWithEmptyResponse, + wrapper: Wrapper, }, ); @@ -203,6 +130,7 @@ describe('useRecordData', () => { it('should call the callback function with fetched data', async () => { const callback = jest.fn(); + mockFetchAllRecords.mockReturnValue([mockPerson]); const { result } = renderHook( () => useExportFetchRecords({ @@ -212,7 +140,7 @@ describe('useRecordData', () => { pageSize: 30, delayMs: 0, }), - { wrapper: WrapperWithResponse }, + { wrapper: Wrapper }, ); await act(async () => { @@ -226,6 +154,7 @@ describe('useRecordData', () => { it('should call the callback function with kanban field included as column if view type is kanban', async () => { const callback = jest.fn(); + mockFetchAllRecords.mockReturnValue([mockPerson]); const { result } = renderHook( () => { const [recordGroupFieldMetadata, setRecordGroupFieldMetadata] = @@ -254,7 +183,7 @@ describe('useRecordData', () => { }; }, { - wrapper: WrapperWithResponse, + wrapper: Wrapper, }, ); @@ -316,6 +245,7 @@ describe('useRecordData', () => { it('should not call the callback function with kanban field included as column if view type is table', async () => { const callback = jest.fn(); + mockFetchAllRecords.mockReturnValue([mockPerson]); const { result } = renderHook( () => { const [recordGroupFieldMetadata, setRecordGroupFieldMetadata] = @@ -345,7 +275,7 @@ describe('useRecordData', () => { }; }, { - wrapper: WrapperWithResponse, + wrapper: Wrapper, }, ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts index bc256a8bcfc6..985d2262846d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/export/hooks/useExportFetchRecords.ts @@ -1,15 +1,11 @@ -import { useEffect, useState } from 'react'; - import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { isDefined } from '~/utils/isDefined'; import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords'; import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize'; import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard'; import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState'; @@ -17,6 +13,7 @@ import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/ import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewType } from '@/views/types/ViewType'; +import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -38,12 +35,6 @@ export type UseRecordDataOptions = { viewType?: ViewType; }; -type ExportProgress = { - exportedRecordCount?: number; - totalRecordCount?: number; - displayType: 'percentage' | 'number'; -}; - export const useExportFetchRecords = ({ objectMetadataItem, delayMs, @@ -53,14 +44,6 @@ export const useExportFetchRecords = ({ callback, viewType = ViewType.Table, }: UseRecordDataOptions) => { - const [isDownloading, setIsDownloading] = useState(false); - const [inflight, setInflight] = useState(false); - const [pageCount, setPageCount] = useState(0); - const [progress, setProgress] = useState({ - displayType: 'number', - }); - const [previousRecordCount, setPreviousRecordCount] = useState(0); - const { hiddenBoardFields } = useObjectOptionsForBoard({ objectNameSingular: objectMetadataItem.nameSingular, recordBoardId: recordIndexId, @@ -99,92 +82,31 @@ export const useExportFetchRecords = ({ recordIndexId, ); - const { findManyRecords, totalCount, records, fetchMoreRecords, loading } = - useLazyFindManyRecords({ - ...findManyRecordsParams, - filter: queryFilter, - limit: pageSize, - }); - - useEffect(() => { - const fetchNextPage = async () => { - setInflight(true); - setPreviousRecordCount(records.length); - - await fetchMoreRecords(); - - setPageCount((state) => state + 1); - setProgress({ - exportedRecordCount: records.length, - totalRecordCount: totalCount, - displayType: totalCount ? 'percentage' : 'number', - }); - await sleep(delayMs); - setInflight(false); - }; - - if (!isDownloading || inflight || loading) { - return; - } - - if ( - pageCount >= maximumRequests || - (isDefined(totalCount) && records.length >= totalCount) - ) { - setPageCount(0); - - const complete = () => { - setPageCount(0); - setPreviousRecordCount(0); - setIsDownloading(false); - setProgress({ - displayType: 'number', - }); - }; - - const finalColumns = [ - ...columns, - ...(hiddenKanbanFieldColumn && viewType === ViewType.Kanban - ? [hiddenKanbanFieldColumn] - : []), - ]; - - const res = callback(records, finalColumns); - - if (res instanceof Promise) { - res.then(complete); - } else { - complete(); - } - } else { - fetchNextPage(); - } - }, [ + const finalColumns = [ + ...columns, + ...(hiddenKanbanFieldColumn && viewType === ViewType.Kanban + ? [hiddenKanbanFieldColumn] + : []), + ]; + + const { progress, isDownloading, fetchAllRecords } = useLazyFetchAllRecords({ + ...findManyRecordsParams, + filter: queryFilter, + limit: pageSize, delayMs, - fetchMoreRecords, - inflight, - isDownloading, - pageCount, - records, - totalCount, - columns, maximumRequests, - pageSize, - loading, - callback, - previousRecordCount, - hiddenKanbanFieldColumn, - viewType, - ]); + }); + + const getTableData = async () => { + const result = await fetchAllRecords(); + if (result.length > 0) { + callback(result, finalColumns); + } + }; return { progress, isDownloading, - getTableData: () => { - setPageCount(0); - setPreviousRecordCount(0); - setIsDownloading(true); - findManyRecords?.(); - }, + getTableData: getTableData, }; }; From 71254bfca0fba6464085530ff059cc387c630639 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 18 Dec 2024 18:09:52 +0100 Subject: [PATCH 02/12] Fix workspace logo (#9129) Follow up on: https://github.com/twentyhq/twenty/issues/9042#issuecomment-2550886611 --- .../components/AppNavigationDrawer.tsx | 13 +++--------- .../SignInAppNavigationDrawerMock.tsx | 21 +++++++++---------- .../components/NavigationDrawer.tsx | 20 +++++++++--------- .../components/NavigationDrawerHeader.tsx | 10 ++++----- 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx b/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx index 93d392c7d080..5f4cbf135269 100644 --- a/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/navigation/components/AppNavigationDrawer.tsx @@ -8,13 +8,10 @@ import { NavigationDrawerProps, } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer'; import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState'; -import { getImageAbsoluteURI } from 'twenty-shared'; -import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer'; import { MainNavigationDrawerItems } from '@/navigation/components/MainNavigationDrawerItems'; -import { isNonEmptyString } from '@sniptt/guards'; import { AdvancedSettingsToggle } from 'twenty-ui'; export type AppNavigationDrawerProps = { @@ -41,15 +38,11 @@ export const AppNavigationDrawer = ({ setIsAdvancedModeEnabled={setIsAdvancedModeEnabled} /> ), + logo: '', } : { - logo: isNonEmptyString(currentWorkspace?.logo) - ? getImageAbsoluteURI({ - imageUrl: currentWorkspace.logo, - baseUrl: REACT_APP_SERVER_BASE_URL, - }) - : undefined, - title: currentWorkspace?.displayName ?? undefined, + logo: currentWorkspace?.logo ?? '', + title: currentWorkspace?.displayName ?? '', children: , footer: , }; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx index d29cf365f8b0..383b4093e6a2 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInAppNavigationDrawerMock.tsx @@ -1,12 +1,11 @@ import { SupportDropdown } from '@/support/components/SupportDropdown'; -import { - NavigationDrawer, - NavigationDrawerProps, -} from '@/ui/navigation/navigation-drawer/components/NavigationDrawer'; +import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer'; import { NavigationDrawerSectionForObjectMetadataItems } from '@/object-metadata/components/NavigationDrawerSectionForObjectMetadataItems'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; +import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; +import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import styled from '@emotion/styled'; import { IconSearch, IconSettings, useIsMobile } from 'twenty-ui'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; @@ -62,14 +61,14 @@ export const SignInAppNavigationDrawerMock = ({ const footer = ; - const drawerProps: NavigationDrawerProps = { - children, - footer, - }; - return ( - - {drawerProps.children} + + {children} ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index ded8a5772cc7..eed58ea01fda 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -19,7 +19,7 @@ export type NavigationDrawerProps = { className?: string; footer?: ReactNode; logo?: string; - title?: string; + title: string; }; const StyledAnimatedContainer = styled(motion.div)<{ isSettings?: boolean }>` @@ -111,15 +111,15 @@ export const NavigationDrawer = ({ onMouseEnter={handleHover} onMouseLeave={handleMouseLeave} > - {isSettingsDrawer && title ? ( - !isMobile && - ) : ( - - )} + {isSettingsDrawer && title + ? !isMobile && + : logo && ( + + )} {children} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index decd0475aa28..5934d54780af 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -3,8 +3,6 @@ import { useRecoilValue } from 'recoil'; import { workspacesState } from '@/auth/states/workspaces'; import { MultiWorkspaceDropdownButton } from '@/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton'; -import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; -import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; @@ -41,14 +39,14 @@ const StyledNavigationDrawerCollapseButton = styled( `; type NavigationDrawerHeaderProps = { - name?: string; - logo?: string; + name: string; + logo: string; showCollapseButton: boolean; }; export const NavigationDrawerHeader = ({ - name = DEFAULT_WORKSPACE_NAME, - logo = DEFAULT_WORKSPACE_LOGO, + name, + logo, showCollapseButton, }: NavigationDrawerHeaderProps) => { const isMobile = useIsMobile(); From f620fd3c1849007c055f39c4abba4ebf1ebdf133 Mon Sep 17 00:00:00 2001 From: Mohammed Abdul Razak Wahab <60781022+mdrazak2001@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:14:00 +0530 Subject: [PATCH 03/12] Fixes person navigation after image upload (#9076) Fixes #8949 --- .../record-show/hooks/useRecordShowPagePagination.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts index f1c68677b1fb..e6e963b8579e 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowPagePagination.ts @@ -62,7 +62,10 @@ export const useRecordShowPagePagination = ( useFindManyRecords({ skip: loadingCursor, fetchPolicy: 'network-only', - filter, + filter: { + ...filter, + id: { neq: objectRecordId }, + }, orderBy, cursorFilter: isNonEmptyString(cursorFromRequest) ? { @@ -81,7 +84,10 @@ export const useRecordShowPagePagination = ( const { loading: loadingRecordAfter, records: recordsAfter } = useFindManyRecords({ skip: loadingCursor, - filter, + filter: { + ...filter, + id: { neq: objectRecordId }, + }, fetchPolicy: 'network-only', orderBy, cursorFilter: cursorFromRequest From a2423fad5e1512c548c7bb3ada2d0346dcea7743 Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 18 Dec 2024 18:56:49 +0100 Subject: [PATCH 04/12] feat(auth): add workspaceId validation and token expiration (#9134) Added validation to ensure refresh tokens include a workspaceId, throwing an exception for malformed tokens. Included workspaceId in payloads and introduced expiration handling for access tokens. This enhances token security and prevents potential misuse. Close #9126 --- .../auth/token/services/access-token.service.ts | 1 + .../auth/token/services/refresh-token.service.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts index bb80d91af594..35e567d474bb 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/access-token.service.ts @@ -100,6 +100,7 @@ export class AccessTokenService { return { token: this.jwtWrapperService.sign(jwtPayload, { secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId), + expiresIn, }), expiresAt, }; diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts index 7dfe5d68ec5f..ea574709bb95 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts @@ -90,6 +90,14 @@ export class RefreshTokenService { ); } + // TODO: Delete this useless condition and error after March 31st 2025 + if (!token.workspaceId) { + throw new AuthException( + 'This refresh token is malformed', + AuthExceptionCode.INVALID_INPUT, + ); + } + return { user, token }; } @@ -115,10 +123,12 @@ export class RefreshTokenService { const refreshTokenPayload = { userId, expiresAt, + workspaceId, type: AppTokenType.RefreshToken, }; const jwtPayload = { sub: userId, + workspaceId, }; const refreshToken = this.appTokenRepository.create(refreshTokenPayload); From 5e03f4dfb17837466bae60a47f83a25b3a2f27cc Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:36:08 +0530 Subject: [PATCH 05/12] fix icon shrink and use avatar for logo and icons (#9117) - Fixed icon shrinking on tabs. The introduction of EllipsisDisplay takes 100% of the width, thus shrinking the icons. - Removed scroll wrapper tablist. This was removed in #9016 but reintroduced in #9089. This reintroduction made the dark border below the active tab disappear. - Used Avatar for icon and logo rendering following the changes made in #9093 --- .../modules/ui/layout/tab/components/Tab.tsx | 36 ++++++-- .../ui/layout/tab/components/TabList.tsx | 92 ++++++++----------- ...rkflowEditActionFormServerlessFunction.tsx | 56 ++++++----- 3 files changed, 100 insertions(+), 84 deletions(-) diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx index 9e7267779581..68c7c7a62c4d 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx @@ -1,10 +1,10 @@ +import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; import isPropValid from '@emotion/is-prop-valid'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { ReactElement } from 'react'; import { Link } from 'react-router-dom'; -import { IconComponent, Pill } from 'twenty-ui'; -import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; +import { Avatar, IconComponent, Pill } from 'twenty-ui'; type TabProps = { id: string; @@ -54,7 +54,7 @@ const StyledHover = styled.span` padding-left: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)}; font-weight: ${({ theme }) => theme.font.weight.medium}; - + width: 100%; &:hover { background: ${({ theme }) => theme.background.tertiary}; border-radius: ${({ theme }) => theme.border.radius.sm}; @@ -63,9 +63,9 @@ const StyledHover = styled.span` background: ${({ theme }) => theme.background.quaternary}; } `; -const StyledLogo = styled.img` - height: 14px; - width: 14px; + +const StyledIconContainer = styled.div` + flex-shrink: 0; `; export const Tab = ({ @@ -81,6 +81,10 @@ export const Tab = ({ logo, }: TabProps) => { const theme = useTheme(); + const iconColor = active + ? theme.font.color.primary + : theme.font.color.secondary; + return ( - {logo && } - {Icon && } + + {logo && ( + + )} + {Icon && ( + + )} + {title} {pill && typeof pill === 'string' ? : pill} diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx index f9fada64c8d8..48709899466b 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx @@ -1,14 +1,12 @@ -import styled from '@emotion/styled'; -import * as React from 'react'; -import { IconComponent } from 'twenty-ui'; - +import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { TabListScope } from '@/ui/layout/tab/scopes/TabListScope'; - -import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect'; import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import styled from '@emotion/styled'; +import * as React from 'react'; import { useEffect } from 'react'; +import { IconComponent } from 'twenty-ui'; import { Tab } from './Tab'; export type SingleTabProps = { @@ -26,33 +24,25 @@ type TabListProps = { tabListInstanceId: string; tabs: SingleTabProps[]; loading?: boolean; - className?: string; behaveAsLinks?: boolean; + className?: string; }; -const StyledTabsContainer = styled.div` +const StyledContainer = styled.div` + border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; box-sizing: border-box; display: flex; gap: ${({ theme }) => theme.spacing(2)}; height: 40px; user-select: none; - margin-bottom: -1px; - overflow-y: scroll; - ::-webkit-scrollbar { - display: none; - } -`; - -const StyledContainer = styled.div` - border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; `; export const TabList = ({ tabs, tabListInstanceId, loading, - className, behaveAsLinks = true, + className, }: TabListProps) => { const visibleTabs = tabs.filter((tab) => !tab.hide); @@ -69,39 +59,37 @@ export const TabList = ({ } return ( - - - tab.id)} - /> - - - {visibleTabs.map((tab) => ( - { - if (!behaveAsLinks) { - setActiveTabId(tab.id); - } - }} - /> - ))} - - - - + + tab.id)} + /> + + + {visibleTabs.map((tab) => ( + { + if (!behaveAsLinks) { + setActiveTabId(tab.id); + } + }} + /> + ))} + + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx index eb4f888b1017..33a924b9a738 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunction.tsx @@ -8,6 +8,22 @@ import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowCodeAction } from '@/workflow/types/Workflow'; import { setNestedValue } from '@/workflow/utils/setNestedValue'; +import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton'; +import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult'; +import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath'; +import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction'; +import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode'; +import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema'; +import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; +import { InputLabel } from '@/ui/input/components/InputLabel'; +import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; +import { TabList } from '@/ui/layout/tab/components/TabList'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; +import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; +import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState'; +import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields'; +import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { Monaco } from '@monaco-editor/react'; @@ -17,22 +33,6 @@ import { useEffect, useState } from 'react'; import { useRecoilState, useRecoilValue } from 'recoil'; import { CodeEditor, IconCode, IconPlayerPlay, isDefined } from 'twenty-ui'; import { useDebouncedCallback } from 'use-debounce'; -import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; -import { TabList } from '@/ui/layout/tab/components/TabList'; -import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; -import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; -import { serverlessFunctionTestDataFamilyState } from '@/workflow/states/serverlessFunctionTestDataFamilyState'; -import { ServerlessFunctionExecutionResult } from '@/serverless-functions/components/ServerlessFunctionExecutionResult'; -import { INDEX_FILE_PATH } from '@/serverless-functions/constants/IndexFilePath'; -import { InputLabel } from '@/ui/input/components/InputLabel'; -import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter'; -import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton'; -import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction'; -import { getFunctionOutputSchema } from '@/serverless-functions/utils/getFunctionOutputSchema'; -import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode'; -import { mergeDefaultFunctionInputAndFunctionInput } from '@/serverless-functions/utils/mergeDefaultFunctionInputAndFunctionInput'; -import { WorkflowEditActionFormServerlessFunctionFields } from '@/workflow/workflow-actions/components/WorkflowEditActionFormServerlessFunctionFields'; -import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId'; const StyledContainer = styled.div` display: flex; @@ -45,10 +45,14 @@ const StyledCodeEditorContainer = styled.div` flex-direction: column; `; -const StyledTabList = styled(TabList)` - background: ${({ theme }) => theme.background.secondary}; +const StyledTabListContainer = styled.div` + align-items: center; padding-left: ${({ theme }) => theme.spacing(2)}; - padding-right: ${({ theme }) => theme.spacing(2)}; + border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; + box-sizing: border-box; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + height: ${({ theme }) => theme.spacing(10)}; `; type WorkflowEditActionFormServerlessFunctionProps = { @@ -263,11 +267,15 @@ export const WorkflowEditActionFormServerlessFunction = ({ return ( !loading && ( - + + + { updateAction({ name: newName }); From 7375ab8d71a5ad71a59b19be37a8c202898ef15d Mon Sep 17 00:00:00 2001 From: Antoine Moreaux Date: Wed, 18 Dec 2024 19:10:16 +0100 Subject: [PATCH 06/12] Fix/refresh token (#9135) --- .../auth/token/services/refresh-token.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts index 5a254c7621d3..5ac7ada61581 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.spec.ts @@ -136,7 +136,7 @@ describe('RefreshTokenService', () => { }); expect(appTokenRepository.save).toHaveBeenCalled(); expect(jwtWrapperService.sign).toHaveBeenCalledWith( - { sub: userId }, + { sub: userId, workspaceId }, expect.objectContaining({ secret: 'mock-secret', expiresIn: mockExpiresIn, From 32fef067348f6e000f975b703dcfd993203119f9 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 19 Dec 2024 10:48:46 +0100 Subject: [PATCH 07/12] Remove react hook form in send email action (#9130) - remove react hook form - put back multiline for body --- .../WorkflowEditActionFormCreateRecord.tsx | 7 - .../WorkflowEditActionFormDeleteRecord.tsx | 11 +- .../WorkflowEditActionFormSendEmail.tsx | 167 +++++++----------- .../WorkflowEditActionFormUpdateRecord.tsx | 9 - 4 files changed, 70 insertions(+), 124 deletions(-) diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx index 58a4ee1140b7..00eb79506162 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormCreateRecord.tsx @@ -99,13 +99,6 @@ export const WorkflowEditActionFormCreateRecord = ({ saveAction(newFormData); }; - useEffect(() => { - setFormData({ - objectName: action.settings.input.objectName, - ...action.settings.input.objectRecord, - }); - }, [action.settings.input]); - const saveAction = useDebouncedCallback( async (formData: CreateRecordFormData) => { if (actionOptions.readonly === true) { diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord.tsx index a7bce5400516..19f8ee6cf3b7 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormDeleteRecord.tsx @@ -1,7 +1,7 @@ import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { Select, SelectOption } from '@/ui/input/components/Select'; -import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader'; import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker'; +import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader'; import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; import { useEffect, useState } from 'react'; @@ -12,9 +12,9 @@ import { useIcons, } from 'twenty-ui'; +import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; import { JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; -import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; type WorkflowEditActionFormDeleteRecordProps = { action: WorkflowDeleteRecordAction; @@ -69,13 +69,6 @@ export const WorkflowEditActionFormDeleteRecord = ({ saveAction(newFormData); }; - useEffect(() => { - setFormData({ - objectName: action.settings.input.objectName, - objectRecordId: action.settings.input.objectRecordId, - }); - }, [action.settings.input]); - const selectedObjectMetadataItemNameSingular = formData.objectName; const selectedObjectMetadataItem = activeObjectMetadataItems.find( diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail.tsx index 74e0bde41a13..3b8842f7c125 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail.tsx @@ -5,17 +5,17 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; import { Select, SelectOption } from '@/ui/input/components/Select'; +import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/components/WorkflowStepHeader'; import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker'; import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowSendEmailAction } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; -import { useEffect } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { IconMail, IconPlus, isDefined } from 'twenty-ui'; +import { JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; -import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; type WorkflowEditActionFormSendEmailProps = { action: WorkflowSendEmailAction; @@ -47,14 +47,11 @@ export const WorkflowEditActionFormSendEmail = ({ const workflowId = useRecoilValue(workflowIdState); const redirectUrl = `/object/workflow/${workflowId}`; - const form = useForm({ - defaultValues: { - connectedAccountId: '', - email: '', - subject: '', - body: '', - }, - disabled: actionOptions.readonly, + const [formData, setFormData] = useState({ + connectedAccountId: action.settings.input.connectedAccountId, + email: action.settings.input.email, + subject: action.settings.input.subject ?? '', + body: action.settings.input.body ?? '', }); const checkConnectedAccountScopes = async ( @@ -78,16 +75,6 @@ export const WorkflowEditActionFormSendEmail = ({ } }; - useEffect(() => { - form.setValue( - 'connectedAccountId', - action.settings.input.connectedAccountId ?? '', - ); - form.setValue('email', action.settings.input.email ?? ''); - form.setValue('subject', action.settings.input.subject ?? ''); - form.setValue('body', action.settings.input.body ?? ''); - }, [action.settings, form]); - const saveAction = useDebouncedCallback( async (formData: SendEmailFormData, checkScopes = false) => { if (actionOptions.readonly === true) { @@ -120,10 +107,19 @@ export const WorkflowEditActionFormSendEmail = ({ }; }, [saveAction]); - const handleSave = (checkScopes = false) => - form.handleSubmit((formData: SendEmailFormData) => - saveAction(formData, checkScopes), - )(); + const handleFieldChange = ( + fieldName: keyof SendEmailFormData, + updatedValue: JsonValue, + ) => { + const newFormData: SendEmailFormData = { + ...formData, + [fieldName]: updatedValue, + }; + + setFormData(newFormData); + + saveAction(newFormData); + }; const filter: { or: object[] } = { or: [ @@ -190,83 +186,56 @@ export const WorkflowEditActionFormSendEmail = ({ headerType="Email" /> - ( - + triggerApisOAuth('google', { + redirectLocation: redirectUrl, + }), + Icon: IconPlus, + text: 'Add account', + }} + onChange={(connectedAccountId) => { + handleFieldChange('connectedAccountId', connectedAccountId); + }} + disabled={actionOptions.readonly} /> - ( - { - field.onChange(value); - handleSave(); - }} - VariablePicker={WorkflowVariablePicker} - /> - )} + { + handleFieldChange('email', email); + }} + VariablePicker={WorkflowVariablePicker} /> - ( - { - field.onChange(value); - handleSave(); - }} - VariablePicker={WorkflowVariablePicker} - /> - )} + { + handleFieldChange('subject', subject); + }} + VariablePicker={WorkflowVariablePicker} /> - ( - { - field.onChange(value); - handleSave(); - }} - VariablePicker={WorkflowVariablePicker} - /> - )} + { + handleFieldChange('body', body); + }} + VariablePicker={WorkflowVariablePicker} + multiline /> diff --git a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx index 6d3745e89370..2061a6900a1d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-actions/components/WorkflowEditActionFormUpdateRecord.tsx @@ -91,15 +91,6 @@ export const WorkflowEditActionFormUpdateRecord = ({ saveAction(newFormData); }; - useEffect(() => { - setFormData({ - objectName: action.settings.input.objectName, - objectRecordId: action.settings.input.objectRecordId, - fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [], - ...action.settings.input.objectRecord, - }); - }, [action.settings.input]); - const selectedObjectMetadataItemNameSingular = formData.objectName; const selectedObjectMetadataItem = activeObjectMetadataItems.find( From e84176dc0d027d0a1b4c1a5c998dfe4c130db8fa Mon Sep 17 00:00:00 2001 From: Mantey Date: Thu, 19 Dec 2024 10:22:13 +0000 Subject: [PATCH 08/12] Reactive form preview (#8663) ## Description This PR fixes issues with field previews not updating immediately when settings are changed in the Data Model Editor. The changes affect number field types, ensuring that the preview updates in real-time as settings are modified. ### Fixed Issues - Number field preview not updating when changing decimals or number type (e.g., percentage) Recording https://www.loom.com/share/14a30f67266d4a08a694c759ae06b0f3?sid=c0de35ef-9982-438b-b822-94ed106f6891 ~~Fixes #8663~~ Fixes #8556 --------- Co-authored-by: Charles Bochet --- .../SettingsDataModelFieldSettingsFormCard.tsx | 2 +- .../SettingsDataModelFieldTextSettingsFormCard.tsx | 8 +++++++- .../SettingsDataModelFieldNumberSettingsFormCard.tsx | 12 ++++++++++-- .../SettingsObjectNewFieldConfigure.tsx | 1 + 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index f0d7f4bdc806..f475ce1b7682 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -117,7 +117,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion( type SettingsDataModelFieldSettingsFormCardProps = { fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'isCustom' + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' > & Partial>; } & Pick; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx index 7dea46004684..1f3c4f395173 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx @@ -8,6 +8,7 @@ import { SettingsDataModelFieldPreviewCard, SettingsDataModelFieldPreviewCardProps, } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; +import { useFormContext } from 'react-hook-form'; type SettingsDataModelFieldTextSettingsFormCardProps = { disabled?: boolean; @@ -26,11 +27,16 @@ export const SettingsDataModelFieldTextSettingsFormCard = ({ fieldMetadataItem, objectMetadataItem, }: SettingsDataModelFieldTextSettingsFormCardProps) => { + const { watch } = useFormContext(); + return ( } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx index edea86760fbf..3a0ef61a3c9d 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx @@ -7,12 +7,13 @@ import { SettingsDataModelFieldPreviewCard, SettingsDataModelFieldPreviewCardProps, } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard'; +import { useFormContext } from 'react-hook-form'; type SettingsDataModelFieldNumberSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'defaultValue' + 'icon' | 'label' | 'type' | 'defaultValue' | 'settings' >; } & Pick; @@ -26,11 +27,18 @@ export const SettingsDataModelFieldNumberSettingsFormCard = ({ fieldMetadataItem, objectMetadataItem, }: SettingsDataModelFieldNumberSettingsFormCardProps) => { + const { watch } = useFormContext(); + return ( } diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx index 3f4713120486..3ccfb6e15fd3 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldConfigure.tsx @@ -227,6 +227,7 @@ export const SettingsObjectNewFieldConfigure = () => { fieldMetadataItem={{ icon: formConfig.watch('icon'), label: formConfig.watch('label') || 'New Field', + settings: formConfig.watch('settings') || null, type: fieldType as FieldMetadataType, }} objectMetadataItem={activeObjectMetadataItem} From 028e5cd9403b794818b1225100c0f84d8d75dbbf Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Thu, 19 Dec 2024 07:30:05 -0300 Subject: [PATCH 09/12] add sync customer command and drop subscription customer constraint (#9131) **TLDR:** Solves (https://github.com/twentyhq/private-issues/issues/212) Add command to sync customer data from stripe to BillingCustomerTable for all active workspaces. Drop foreign key contraint on billingCustomer in BillingSubscription (in order to not break the DB). **In order to test:** - Billing should be enabled - Have some workspaces that are active and whose id's are not mentioned in BillingCustomer (but the customer are present in stripe). Run the command: `npx nx run twenty-server:command billing:sync-customer-data` Take into consideration Due that all the previous subscriptions in Stripe have the workspaceId in their metadata, we use that information as source of true for the data sync **Things to do:** - Add tests for Billing utils - Separate StripeService into multipleServices (stripeSubscriptionService, stripePriceService etc) perhaps add them in (https://github.com/twentyhq/private-issues/issues/201)? --- ...450749954-addConstraintsOnBillingTables.ts | 6 -- .../core-modules/billing/billing.module.ts | 2 + .../billing-sync-customer-data.command.ts | 97 +++++++++++++++++++ .../entities/billing-subscription.entity.ts | 1 + .../billing/stripe/stripe.service.ts | 12 +++ 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/billing/1734450749954-addConstraintsOnBillingTables.ts b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1734450749954-addConstraintsOnBillingTables.ts index 668fd4a8f4f6..4371cae8cba3 100644 --- a/packages/twenty-server/src/database/typeorm/core/migrations/billing/1734450749954-addConstraintsOnBillingTables.ts +++ b/packages/twenty-server/src/database/typeorm/core/migrations/billing/1734450749954-addConstraintsOnBillingTables.ts @@ -30,15 +30,9 @@ export class AddConstraintsOnBillingTables1734450749954 await queryRunner.query( `ALTER TABLE "core"."billingEntitlement" ADD CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356" FOREIGN KEY ("stripeCustomerId") REFERENCES "core"."billingCustomer"("stripeCustomerId") ON DELETE CASCADE ON UPDATE NO ACTION`, ); - await queryRunner.query( - `ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_9120b7586c3471463480b58d20a" FOREIGN KEY ("stripeCustomerId") REFERENCES "core"."billingCustomer"("stripeCustomerId") ON DELETE CASCADE ON UPDATE NO ACTION`, - ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_9120b7586c3471463480b58d20a"`, - ); await queryRunner.query( `ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_766a1918aa3dbe0d67d3df62356"`, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index 39e387c0377d..16acfa3987c1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingController } from 'src/engine/core-modules/billing/billing.controller'; import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver'; +import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command'; import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-meter.entity'; @@ -59,6 +60,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; BillingWebhookProductService, BillingWebhookPriceService, BillingRestApiExceptionFilter, + BillingSyncCustomerDataCommand, ], exports: [ BillingSubscriptionService, diff --git a/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts new file mode 100644 index 000000000000..70fcb981a080 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/commands/billing-sync-customer-data.command.ts @@ -0,0 +1,97 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import chalk from 'chalk'; +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { + ActiveWorkspacesCommandOptions, + ActiveWorkspacesCommandRunner, +} from 'src/database/commands/active-workspaces.command'; +import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity'; +import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +interface SyncCustomerDataCommandOptions + extends ActiveWorkspacesCommandOptions {} + +@Command({ + name: 'billing:sync-customer-data', + description: 'Sync customer data from Stripe for all active workspaces', +}) +export class BillingSyncCustomerDataCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + private readonly stripeService: StripeService, + @InjectRepository(BillingCustomer, 'core') + protected readonly billingCustomerRepository: Repository, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + options: SyncCustomerDataCommandOptions, + workspaceIds: string[], + ): Promise { + this.logger.log('Running command to sync customer data'); + + for (const workspaceId of workspaceIds) { + this.logger.log(`Running command for workspace ${workspaceId}`); + + try { + await this.syncCustomerDataForWorkspace(workspaceId, options); + } catch (error) { + this.logger.log( + chalk.red( + `Running command on workspace ${workspaceId} failed with error: ${error}, ${error.stack}`, + ), + ); + continue; + } finally { + this.logger.log( + chalk.green(`Finished running command for workspace ${workspaceId}.`), + ); + } + } + + this.logger.log(chalk.green(`Command completed!`)); + } + + private async syncCustomerDataForWorkspace( + workspaceId: string, + options: SyncCustomerDataCommandOptions, + ): Promise { + const billingCustomer = await this.billingCustomerRepository.findOne({ + where: { + workspaceId, + }, + }); + + if (!options.dryRun && !billingCustomer) { + const stripeCustomerId = + await this.stripeService.getStripeCustomerIdFromWorkspaceId( + workspaceId, + ); + + if (stripeCustomerId) { + await this.billingCustomerRepository.upsert( + { + stripeCustomerId, + workspaceId, + }, + { + conflictPaths: ['workspaceId'], + }, + ); + } + } + + if (options.verbose) { + this.logger.log( + chalk.yellow(`Added ${workspaceId} to billingCustomer table`), + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts index 88e4caffc9f7..419c72990628 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts @@ -82,6 +82,7 @@ export class BillingSubscription { { nullable: false, onDelete: 'CASCADE', + createForeignKeyConstraints: false, }, ) @JoinColumn({ diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index 85e75f705219..06944358c973 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -194,4 +194,16 @@ export class StripeService { return productPrices.sort((a, b) => a.unitAmount - b.unitAmount); } + + async getStripeCustomerIdFromWorkspaceId(workspaceId: string) { + const subscription = await this.stripe.subscriptions.search({ + query: `metadata['workspaceId']:'${workspaceId}'`, + limit: 1, + }); + const stripeCustomerId = subscription.data[0].customer + ? String(subscription.data[0].customer) + : undefined; + + return stripeCustomerId; + } } From 65586a00ccd41115be326278aa58686c38cd4216 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 19 Dec 2024 11:46:21 +0100 Subject: [PATCH 10/12] Add date time form field (#9133) - Create a really simple abstraction to unify the date and date time fields. We might dissociate them sooner than expected. - The _relative_ setting is ignored --- .../components/FormFieldInput.tsx | 14 +- ...put.tsx => FormDateTimeFieldInputBase.tsx} | 24 +- .../FormDateFieldInput.stories.tsx | 370 --------- .../FormDateTimeFieldInputBase.stories.tsx | 765 ++++++++++++++++++ 4 files changed, 791 insertions(+), 382 deletions(-) rename packages/twenty-front/src/modules/object-record/record-field/form-types/components/{FormDateFieldInput.tsx => FormDateTimeFieldInputBase.tsx} (94%) delete mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateFieldInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx index 6d7424d18576..a3fa74cc5aa4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FormFieldInput.tsx @@ -1,6 +1,6 @@ import { FormAddressFieldInput } from '@/object-record/record-field/form-types/components/FormAddressFieldInput'; import { FormBooleanFieldInput } from '@/object-record/record-field/form-types/components/FormBooleanFieldInput'; -import { FormDateFieldInput } from '@/object-record/record-field/form-types/components/FormDateFieldInput'; +import { FormDateTimeFieldInputBase } from '@/object-record/record-field/form-types/components/FormDateTimeFieldInputBase'; import { FormEmailsFieldInput } from '@/object-record/record-field/form-types/components/FormEmailsFieldInput'; import { FormFullNameFieldInput } from '@/object-record/record-field/form-types/components/FormFullNameFieldInput'; import { FormLinksFieldInput } from '@/object-record/record-field/form-types/components/FormLinksFieldInput'; @@ -23,6 +23,7 @@ import { import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; +import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime'; import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; @@ -108,7 +109,16 @@ export const FormFieldInput = ({ VariablePicker={VariablePicker} /> ) : isFieldDate(field) ? ( - + ) : isFieldDateTime(field) ? ( + void; VariablePicker?: VariablePickerComponent; }; -export const FormDateFieldInput = ({ +export const FormDateTimeFieldInputBase = ({ + mode, label, defaultValue, onPersist, VariablePicker, -}: FormDateFieldInputProps) => { +}: FormDateTimeFieldInputBaseProps) => { const { timeZone } = useContext(UserContext); const inputId = useId(); + const placeholder = mode === 'date' ? 'mm/dd/yyyy' : 'mm/dd/yyyy hh:mm'; + const [draftValue, setDraftValue] = useState( isStandaloneVariableString(defaultValue) ? { @@ -112,7 +116,7 @@ export const FormDateFieldInput = ({ isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue) ? parseDateToString({ date: draftValueAsDate, - isDateTimeInput: false, + isDateTimeInput: mode === 'datetime', userTimezone: timeZone, }) : '', @@ -141,7 +145,7 @@ export const FormDateFieldInput = ({ useListenClickOutside({ refs: [datePickerWrapperRef], - listenerId: 'FormDateFieldInput', + listenerId: 'FormDateTimeFieldInputBase', callback: (event) => { event.stopImmediatePropagation(); @@ -164,7 +168,7 @@ export const FormDateFieldInput = ({ isDefined(newDate) ? parseDateToString({ date: newDate, - isDateTimeInput: false, + isDateTimeInput: mode === 'datetime', userTimezone: timeZone, }) : '', @@ -222,7 +226,7 @@ export const FormDateFieldInput = ({ isDefined(newDate) ? parseDateToString({ date: newDate, - isDateTimeInput: false, + isDateTimeInput: mode === 'datetime', userTimezone: timeZone, }) : '', @@ -258,7 +262,7 @@ export const FormDateFieldInput = ({ const parsedInputDateTime = parseStringToDate({ dateAsString: inputDateTimeTrimmed, - isDateTimeInput: false, + isDateTimeInput: mode === 'datetime', userTimezone: timeZone, }); @@ -284,7 +288,7 @@ export const FormDateFieldInput = ({ setInputDateTime( parseDateToString({ date: validatedDate, - isDateTimeInput: false, + isDateTimeInput: mode === 'datetime', userTimezone: timeZone, }), ); @@ -328,7 +332,7 @@ export const FormDateFieldInput = ({ <> = { - title: 'UI/Data/Field/Form/Input/FormDateFieldInput', - component: FormDateFieldInput, - args: {}, - argTypes: {}, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - label: 'Created At', - defaultValue: '2024-12-09T13:20:19.631Z', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue('12/09/2024'); - }, -}; - -export const WithDefaultEmptyValue: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await canvas.findByText('Created At'); - await canvas.findByDisplayValue(''); - await canvas.findByPlaceholderText('mm/dd/yyyy'); - }, -}; - -export const SetsDateWithInput: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - - await userEvent.click(input); - - const dialog = await canvas.findByRole('dialog'); - expect(dialog).toBeVisible(); - - await userEvent.type(input, '12/08/2024{enter}'); - - await waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z'); - }); - - expect(dialog).toBeVisible(); - }, -}; - -export const SetsDateWithDatePicker: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const dayToChoose = await within(datePicker).findByRole('option', { - name: 'Choose Saturday, December 7th, 2024', - }); - - await Promise.all([ - userEvent.click(dayToChoose), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith( - expect.stringMatching(/^2024-12-07/), - ); - }), - waitFor(() => { - expect(canvas.getByDisplayValue('12/07/2024')).toBeVisible(); - }), - ]); - }, -}; - -export const ResetsDateByClickingButton: Story = { - args: { - label: 'Created At', - defaultValue: '2024-12-09T13:20:19.631Z', - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - const clearButton = await canvas.findByText('Clear'); - - await Promise.all([ - userEvent.click(clearButton), - - waitForElementToBeRemoved(datePicker), - waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith(null); - }), - waitFor(() => { - expect(input).toHaveDisplayValue(''); - }), - ]); - }, -}; - -export const ResetsDateByErasingInputContent: Story = { - args: { - label: 'Created At', - defaultValue: '2024-12-09T13:20:19.631Z', - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - expect(input).toHaveDisplayValue('12/09/2024'); - - await userEvent.clear(input); - - await Promise.all([ - userEvent.type(input, '{Enter}'), - - waitForElementToBeRemoved(() => canvas.queryByRole('dialog')), - waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith(null); - }), - waitFor(() => { - expect(input).toHaveDisplayValue(''); - }), - ]); - }, -}; - -export const DefaultsToMinValueWhenTypingReallyOldDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.type(input, '02/02/1500{Enter}'), - - waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString()); - }), - waitFor(() => { - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MIN_DATE, - isDateTimeInput: false, - userTimezone: undefined, - }), - ); - }), - waitFor(() => { - const expectedDate = DateTime.fromJSDate(MIN_DATE) - .toLocal() - .set({ - day: MIN_DATE.getUTCDate(), - month: MIN_DATE.getUTCMonth() + 1, - year: MIN_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Sunday, December 31st, 1899" - return accessibleName.includes(expectedDate.toFormat('yyyy')); - }, - }); - expect(selectedDay).toBeVisible(); - }), - ]); - }, -}; - -export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - - await userEvent.click(input); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.type(input, '02/02/2500{Enter}'), - - waitFor(() => { - expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString()); - }), - waitFor(() => { - expect(input).toHaveDisplayValue( - parseDateToString({ - date: MAX_DATE, - isDateTimeInput: false, - userTimezone: undefined, - }), - ); - }), - waitFor(() => { - const expectedDate = DateTime.fromJSDate(MAX_DATE) - .toLocal() - .set({ - day: MAX_DATE.getUTCDate(), - month: MAX_DATE.getUTCMonth() + 1, - year: MAX_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); - - const selectedDay = within(datePicker).getByRole('option', { - selected: true, - name: (accessibleName) => { - // The name looks like "Choose Thursday, December 30th, 2100" - return accessibleName.includes(expectedDate.toFormat('yyyy')); - }, - }); - expect(selectedDay).toBeVisible(); - }), - ]); - }, -}; - -export const SwitchesToStandaloneVariable: Story = { - args: { - label: 'Created At', - defaultValue: undefined, - onPersist: fn(), - VariablePicker: ({ onVariableSelect }) => { - return ( - - ); - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const addVariableButton = await canvas.findByText('Add variable'); - await userEvent.click(addVariableButton); - - const variableTag = await canvas.findByText('test'); - expect(variableTag).toBeVisible(); - - const removeVariableButton = canvas.getByTestId(/^remove-icon/); - - await Promise.all([ - userEvent.click(removeVariableButton), - - waitForElementToBeRemoved(variableTag), - waitFor(() => { - const input = canvas.getByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - }), - ]); - }, -}; - -export const ClickingOutsideDoesNotResetInputState: Story = { - args: { - label: 'Created At', - defaultValue: '2024-12-09T13:20:19.631Z', - onPersist: fn(), - }, - play: async ({ canvasElement, args }) => { - const defaultValueAsDisplayString = parseDateToString({ - date: new Date(args.defaultValue!), - isDateTimeInput: false, - userTimezone: undefined, - }); - - const canvas = within(canvasElement); - - const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); - expect(input).toBeVisible(); - expect(input).toHaveDisplayValue(defaultValueAsDisplayString); - - await userEvent.type(input, '{Backspace}{Backspace}'); - - const datePicker = await canvas.findByRole('dialog'); - expect(datePicker).toBeVisible(); - - await Promise.all([ - userEvent.click(canvasElement), - - waitForElementToBeRemoved(datePicker), - ]); - - expect(args.onPersist).not.toHaveBeenCalled(); - - expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2)); - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx new file mode 100644 index 000000000000..cad93289e0c6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/components/__stories__/FormDateTimeFieldInputBase.stories.tsx @@ -0,0 +1,765 @@ +import { MAX_DATE } from '@/ui/input/components/internal/date/constants/MaxDate'; +import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate'; +import { parseDateToString } from '@/ui/input/components/internal/date/utils/parseDateToString'; +import { expect } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { + fn, + userEvent, + waitFor, + waitForElementToBeRemoved, + within, +} from '@storybook/test'; +import { DateTime } from 'luxon'; +import { FormDateTimeFieldInputBase } from '../FormDateTimeFieldInputBase'; + +const meta: Meta = { + title: 'UI/Data/Field/Form/Input/FormDateTimeFieldInputBase', + component: FormDateTimeFieldInputBase, + args: {}, + argTypes: {}, +}; + +export default meta; + +type Story = StoryObj; + +export const DateDefault: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Created At'); + await canvas.findByDisplayValue('12/09/2024'); + }, +}; + +export const DateWithDefaultEmptyValue: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Created At'); + await canvas.findByDisplayValue(''); + await canvas.findByPlaceholderText('mm/dd/yyyy'); + }, +}; + +export const DateSetsDateWithInput: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + + await userEvent.click(input); + + const dialog = await canvas.findByRole('dialog'); + expect(dialog).toBeVisible(); + + await userEvent.type(input, '12/08/2024{enter}'); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith('2024-12-08T00:00:00.000Z'); + }); + + expect(dialog).toBeVisible(); + }, +}; + +export const DateSetsDateWithDatePicker: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + const dayToChoose = await within(datePicker).findByRole('option', { + name: 'Choose Saturday, December 7th, 2024', + }); + + await Promise.all([ + userEvent.click(dayToChoose), + + waitForElementToBeRemoved(datePicker), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith( + expect.stringMatching(/^2024-12-07/), + ); + }), + waitFor(() => { + expect(canvas.getByDisplayValue('12/07/2024')).toBeVisible(); + }), + ]); + }, +}; + +export const DateResetsDateByClickingButton: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + const clearButton = await canvas.findByText('Clear'); + + await Promise.all([ + userEvent.click(clearButton), + + waitForElementToBeRemoved(datePicker), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + waitFor(() => { + expect(input).toHaveDisplayValue(''); + }), + ]); + }, +}; + +export const DateResetsDateByErasingInputContent: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + + expect(input).toHaveDisplayValue('12/09/2024'); + + await userEvent.clear(input); + + await Promise.all([ + userEvent.type(input, '{Enter}'), + + waitForElementToBeRemoved(() => canvas.queryByRole('dialog')), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + waitFor(() => { + expect(input).toHaveDisplayValue(''); + }), + ]); + }, +}; + +export const DateDefaultsToMinValueWhenTypingReallyOldDate: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.type(input, '02/02/1500{Enter}'), + + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString()); + }), + waitFor(() => { + expect(input).toHaveDisplayValue( + parseDateToString({ + date: MIN_DATE, + isDateTimeInput: false, + userTimezone: undefined, + }), + ); + }), + waitFor(() => { + const expectedDate = DateTime.fromJSDate(MIN_DATE) + .toLocal() + .set({ + day: MIN_DATE.getUTCDate(), + month: MIN_DATE.getUTCMonth() + 1, + year: MIN_DATE.getUTCFullYear(), + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + const selectedDay = within(datePicker).getByRole('option', { + selected: true, + name: (accessibleName) => { + // The name looks like "Choose Sunday, December 31st, 1899" + return accessibleName.includes(expectedDate.toFormat('yyyy')); + }, + }); + expect(selectedDay).toBeVisible(); + }), + ]); + }, +}; + +export const DateDefaultsToMaxValueWhenTypingReallyFarDate: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.type(input, '02/02/2500{Enter}'), + + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString()); + }), + waitFor(() => { + expect(input).toHaveDisplayValue( + parseDateToString({ + date: MAX_DATE, + isDateTimeInput: false, + userTimezone: undefined, + }), + ); + }), + waitFor(() => { + const expectedDate = DateTime.fromJSDate(MAX_DATE) + .toLocal() + .set({ + day: MAX_DATE.getUTCDate(), + month: MAX_DATE.getUTCMonth() + 1, + year: MAX_DATE.getUTCFullYear(), + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + const selectedDay = within(datePicker).getByRole('option', { + selected: true, + name: (accessibleName) => { + // The name looks like "Choose Thursday, December 30th, 2100" + return accessibleName.includes(expectedDate.toFormat('yyyy')); + }, + }); + expect(selectedDay).toBeVisible(); + }), + ]); + }, +}; + +export const DateSwitchesToStandaloneVariable: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + VariablePicker: ({ onVariableSelect }) => { + return ( + + ); + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const addVariableButton = await canvas.findByText('Add variable'); + await userEvent.click(addVariableButton); + + const variableTag = await canvas.findByText('test'); + expect(variableTag).toBeVisible(); + + const removeVariableButton = canvas.getByTestId(/^remove-icon/); + + await Promise.all([ + userEvent.click(removeVariableButton), + + waitForElementToBeRemoved(variableTag), + waitFor(() => { + const input = canvas.getByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + }), + ]); + }, +}; + +export const DateClickingOutsideDoesNotResetInputState: Story = { + args: { + mode: 'date', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const defaultValueAsDisplayString = parseDateToString({ + date: new Date(args.defaultValue!), + isDateTimeInput: false, + userTimezone: undefined, + }); + + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy'); + expect(input).toBeVisible(); + expect(input).toHaveDisplayValue(defaultValueAsDisplayString); + + await userEvent.type(input, '{Backspace}{Backspace}'); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.click(canvasElement), + + waitForElementToBeRemoved(datePicker), + ]); + + expect(args.onPersist).not.toHaveBeenCalled(); + + expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2)); + }, +}; + +// ---- + +export const DateTimeDefault: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Created At'); + await canvas.findByDisplayValue(/12\/09\/2024 \d{2}:20/); + }, +}; + +export const DateTimeWithDefaultEmptyValue: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await canvas.findByText('Created At'); + await canvas.findByDisplayValue(''); + await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + }, +}; + +export const DateTimeSetsDateTimeWithInput: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + + await userEvent.click(input); + + const dialog = await canvas.findByRole('dialog'); + expect(dialog).toBeVisible(); + + await userEvent.type(input, '12/08/2024 12:10{enter}'); + + await waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith( + expect.stringMatching(/2024-12-08T\d{2}:10:00.000Z/), + ); + }); + + expect(dialog).toBeVisible(); + }, +}; + +export const DateTimeDoesNotSetDateWithoutTime: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + + await userEvent.click(input); + + const dialog = await canvas.findByRole('dialog'); + expect(dialog).toBeVisible(); + + await userEvent.type(input, '12/08/2024{enter}'); + + expect(args.onPersist).not.toHaveBeenCalled(); + expect(dialog).toBeVisible(); + }, +}; + +export const DateTimeSetsDateTimeWithDatePicker: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + const dayToChoose = await within(datePicker).findByRole('option', { + name: 'Choose Saturday, December 7th, 2024', + }); + + await Promise.all([ + userEvent.click(dayToChoose), + + waitForElementToBeRemoved(datePicker), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith( + expect.stringMatching(/^2024-12-07/), + ); + }), + waitFor(() => { + expect( + canvas.getByDisplayValue(/12\/07\/2024 \d{2}:\d{2}/), + ).toBeVisible(); + }), + ]); + }, +}; + +export const DateTimeResetsDateByClickingButton: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + const clearButton = await canvas.findByText('Clear'); + + await Promise.all([ + userEvent.click(clearButton), + + waitForElementToBeRemoved(datePicker), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + waitFor(() => { + expect(input).toHaveDisplayValue(''); + }), + ]); + }, +}; + +export const DateTimeResetsDateByErasingInputContent: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + + expect(input).toHaveDisplayValue(/12\/09\/2024 \d{2}:\d{2}/); + + await userEvent.clear(input); + + await Promise.all([ + userEvent.type(input, '{Enter}'), + + waitForElementToBeRemoved(() => canvas.queryByRole('dialog')), + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(null); + }), + waitFor(() => { + expect(input).toHaveDisplayValue(''); + }), + ]); + }, +}; + +export const DateTimeDefaultsToMinValueWhenTypingReallyOldDate: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.type(input, '02/02/1500 10:10{Enter}'), + + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(MIN_DATE.toISOString()); + }), + waitFor(() => { + expect(input).toHaveDisplayValue( + parseDateToString({ + date: MIN_DATE, + isDateTimeInput: true, + userTimezone: undefined, + }), + ); + }), + waitFor(() => { + const expectedDate = DateTime.fromJSDate(MIN_DATE) + .toLocal() + .set({ + day: MIN_DATE.getUTCDate(), + month: MIN_DATE.getUTCMonth() + 1, + year: MIN_DATE.getUTCFullYear(), + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + const selectedDay = within(datePicker).getByRole('option', { + selected: true, + name: (accessibleName) => { + // The name looks like "Choose Sunday, December 31st, 1899" + return accessibleName.includes(expectedDate.toFormat('yyyy')); + }, + }); + expect(selectedDay).toBeVisible(); + }), + ]); + }, +}; + +export const DateTimeDefaultsToMaxValueWhenTypingReallyFarDate: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + + await userEvent.click(input); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.type(input, '02/02/2500 10:10{Enter}'), + + waitFor(() => { + expect(args.onPersist).toHaveBeenCalledWith(MAX_DATE.toISOString()); + }), + waitFor(() => { + expect(input).toHaveDisplayValue( + parseDateToString({ + date: MAX_DATE, + isDateTimeInput: true, + userTimezone: undefined, + }), + ); + }), + waitFor(() => { + const expectedDate = DateTime.fromJSDate(MAX_DATE) + .toLocal() + .set({ + day: MAX_DATE.getUTCDate(), + month: MAX_DATE.getUTCMonth() + 1, + year: MAX_DATE.getUTCFullYear(), + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + const selectedDay = within(datePicker).getByRole('option', { + selected: true, + name: (accessibleName) => { + // The name looks like "Choose Thursday, December 30th, 2100" + return accessibleName.includes(expectedDate.toFormat('yyyy')); + }, + }); + expect(selectedDay).toBeVisible(); + }), + ]); + }, +}; + +export const DateTimeSwitchesToStandaloneVariable: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: undefined, + onPersist: fn(), + VariablePicker: ({ onVariableSelect }) => { + return ( + + ); + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const addVariableButton = await canvas.findByText('Add variable'); + await userEvent.click(addVariableButton); + + const variableTag = await canvas.findByText('test'); + expect(variableTag).toBeVisible(); + + const removeVariableButton = canvas.getByTestId(/^remove-icon/); + + await Promise.all([ + userEvent.click(removeVariableButton), + + waitForElementToBeRemoved(variableTag), + waitFor(() => { + const input = canvas.getByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + }), + ]); + }, +}; + +export const DateTimeClickingOutsideDoesNotResetInputState: Story = { + args: { + mode: 'datetime', + label: 'Created At', + defaultValue: '2024-12-09T13:20:19.631Z', + onPersist: fn(), + }, + play: async ({ canvasElement, args }) => { + const defaultValueAsDisplayString = parseDateToString({ + date: new Date(args.defaultValue!), + isDateTimeInput: true, + userTimezone: undefined, + }); + + const canvas = within(canvasElement); + + const input = await canvas.findByPlaceholderText('mm/dd/yyyy hh:mm'); + expect(input).toBeVisible(); + expect(input).toHaveDisplayValue(defaultValueAsDisplayString); + + await userEvent.type(input, '{Backspace}{Backspace}'); + + const datePicker = await canvas.findByRole('dialog'); + expect(datePicker).toBeVisible(); + + await Promise.all([ + userEvent.click(canvasElement), + + waitForElementToBeRemoved(datePicker), + ]); + + expect(args.onPersist).not.toHaveBeenCalled(); + + expect(input).toHaveDisplayValue(defaultValueAsDisplayString.slice(0, -2)); + }, +}; From 784bc78ed08bbc12cbf113afeb35a5141dcb522a Mon Sep 17 00:00:00 2001 From: Harsh Singh Date: Thu, 19 Dec 2024 16:21:03 +0530 Subject: [PATCH 11/12] add: objectName in fav folder (#8785) Closes: #8549 It was quite complex to get this right. So, I went through Notion's website to see how they implemented it. Instead of using `display: none` or having a space reserved for the Icon, I used clip-path & opacity trick to achieve the desired behaviour. This maintains accessibility and helps in label or ObjectName to take the full space. Also, truncation now works for label & objectName as a whole instead of separately, as seen in my previous PR. **Caveats** The only problem that now remains is not having `NavigationDrawerAnimatedCollapseWrapper`. Having it on top of any text or div won't let the flex or truncation property work. [Screencast from 2024-11-28 13-37-31.webm](https://github.com/user-attachments/assets/29255cd2-3f15-4b1d-b1e1-c041c70052e5) --------- Co-authored-by: ehconitin Co-authored-by: martmull --- .../CurrentWorkspaceMemberFavorites.tsx | 1 + .../CurrentWorkspaceMemberOrphanFavorites.tsx | 1 + .../components/NavigationDrawerItem.tsx | 154 +++++++++++++----- .../components/NavigationDrawerSubItem.tsx | 2 + 4 files changed, 114 insertions(+), 44 deletions(-) diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx index 66219672e25a..25621d4bb4eb 100644 --- a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberFavorites.tsx @@ -173,6 +173,7 @@ export const CurrentWorkspaceMemberFavorites = ({ itemComponent={ } to={favorite.link} active={index === selectedFavoriteIndex} diff --git a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx index a32fdbd33b01..2db37d492891 100644 --- a/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx +++ b/packages/twenty-front/src/modules/favorites/components/CurrentWorkspaceMemberOrphanFavorites.tsx @@ -59,6 +59,7 @@ export const CurrentWorkspaceMemberOrphanFavorites = () => { accent="tertiary" /> } + objectName={favorite.objectNameSingular} isDragging={isDragging} /> diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx index aec9e0fdefe9..1d924f55c413 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItem.tsx @@ -6,7 +6,7 @@ import { NavigationDrawerSubItemState } from '@/ui/navigation/navigation-drawer/ import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import isPropValid from '@emotion/is-prop-valid'; -import { useTheme } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { ReactNode } from 'react'; import { Link } from 'react-router-dom'; @@ -18,6 +18,7 @@ import { TablerIconsProps, } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; +import { capitalize } from '~/utils/string/capitalize'; const DEFAULT_INDENTATION_LEVEL = 1; @@ -26,6 +27,7 @@ export type NavigationDrawerItemIndentationLevel = 1 | 2; export type NavigationDrawerItemProps = { className?: string; label: string; + objectName?: string; indentationLevel?: NavigationDrawerItemIndentationLevel; subItemState?: NavigationDrawerSubItemState; to?: string; @@ -87,13 +89,15 @@ const StyledItem = styled('button', { width: ${(props) => !props.isNavigationDrawerExpanded - ? `${NAV_DRAWER_WIDTHS.menu.desktop.collapsed - 24}px` - : '100%'}; + ? `calc(${NAV_DRAWER_WIDTHS.menu.desktop.collapsed}px - ${props.theme.spacing(6)})` + : `calc(100% - ${props.theme.spacing(2)})`}; + ${({ isDragging }) => isDragging && - ` - cursor: grabbing; - `} + ` + cursor: grabbing; + `} + :hover { background: ${({ theme }) => theme.background.transparent.light}; color: ${(props) => @@ -111,19 +115,37 @@ const StyledItem = styled('button', { } `; -const StyledItemElementsContainer = styled.span` +const StyledItemElementsContainer = styled.div` align-items: center; display: flex; - gap: ${({ theme }) => theme.spacing(2)}; width: 100%; `; -const StyledItemLabel = styled.span` - font-weight: ${({ theme }) => theme.font.weight.medium}; +const StyledLabelParent = styled.div` + display: flex; + align-items: center; + flex: 1 1 auto; + white-space: nowrap; + min-width: 0px; + overflow: hidden; + text-overflow: clip; +`; +const StyledEllipsisContainer = styled.div` + color: ${({ theme }) => theme.font.color.light}; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `; +const StyledItemLabel = styled.span` + color: ${({ theme }) => theme.font.color.secondary}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; +const StyledItemObjectName = styled.span` + color: ${({ theme }) => theme.font.color.light}; + font-weight: ${({ theme }) => theme.font.weight.regular}; +`; + const StyledItemCount = styled.span` align-items: center; background-color: ${({ theme }) => theme.color.blue}; @@ -149,7 +171,7 @@ const StyledKeyBoardShortcut = styled.span` visibility: hidden; `; -const StyledNavigationDrawerItemContainer = styled.span` +const StyledNavigationDrawerItemContainer = styled.div` display: flex; width: 100%; `; @@ -158,30 +180,58 @@ const StyledSpacer = styled.span` flex-grow: 1; `; -const StyledRightOptionsContainer = styled.div<{ - isMobile: boolean; - active: boolean; -}>` - margin-left: auto; - visibility: ${({ isMobile, active }) => - isMobile || active ? 'visible' : 'hidden'}; +const StyledIcon = styled.div` + flex-shrink: 0; + flex-grow: 0; + margin-right: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledRightOptionsContainer = styled.div` display: flex; align-items: center; justify-content: center; - :hover { - background: ${({ theme }) => theme.background.transparent.light}; - } - width: ${({ theme }) => theme.spacing(6)}; + flex-shrink: 0; + flex-grow: 0; height: ${({ theme }) => theme.spacing(6)}; border-radius: ${({ theme }) => theme.border.radius.sm}; +`; + +const visibleStateStyles = css` + clip-path: unset; + display: flex; + height: unset; + opacity: 1; + overflow: unset; + position: unset; + width: unset; +`; + +const StyledRightOptionsVisbility = styled.div<{ + isMobile: boolean; + active: boolean; +}>` + display: block; + opacity: 0; + transition: opacity 150ms; + position: absolute; + padding-left: ${({ theme }) => theme.spacing(2)}; + overflow: hidden; + clip-path: inset(1px); + white-space: nowrap; + height: 1px; + width: 1px; + + ${({ isMobile, active }) => (isMobile || active) && visibleStateStyles} + .navigation-drawer-item:hover & { - visibility: visible; + ${visibleStateStyles} } `; export const NavigationDrawerItem = ({ className, label, + objectName, indentationLevel = DEFAULT_INDENTATION_LEVEL, Icon, to, @@ -228,28 +278,41 @@ export const NavigationDrawerItem = ({ isNavigationDrawerExpanded={isNavigationDrawerExpanded} isDragging={isDragging} > - {showBreadcrumb && ( - - - - )} + {showBreadcrumb && ( + + + + )} + {Icon && ( - + + + )} - - {label} - + + + {label} + {objectName && ( + + {' ยท '} + {capitalize(objectName)} + + )} + + @@ -275,14 +338,17 @@ export const NavigationDrawerItem = ({ {rightOptions && ( { e.stopPropagation(); e.preventDefault(); }} > - {rightOptions} + + {rightOptions} + )} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem.tsx index 833afe82758e..857c00a7277b 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem.tsx @@ -8,6 +8,7 @@ type NavigationDrawerSubItemProps = NavigationDrawerItemProps; export const NavigationDrawerSubItem = ({ className, label, + objectName, Icon, to, onClick, @@ -24,6 +25,7 @@ export const NavigationDrawerSubItem = ({ Date: Thu, 19 Dec 2024 14:19:25 +0100 Subject: [PATCH 12/12] Fix version creation / update when opening an action (#9145) Actions using tiptap trigger updates when called. Flow is following: - component using tiptap receive default value - we separate the default value because we need to add specific attributes to the editor when inserting breaks or variables - editor performs an update for each value It means that initialize our editor performs several updates. We need to avoid persisting until the init finished. --- .../form-types/hooks/useTextVariableEditor.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts index d184e1dab418..a00c52f26e09 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/form-types/hooks/useTextVariableEditor.ts @@ -6,6 +6,7 @@ import Paragraph from '@tiptap/extension-paragraph'; import { default as Placeholder } from '@tiptap/extension-placeholder'; import Text from '@tiptap/extension-text'; import { Editor, useEditor } from '@tiptap/react'; +import { useState } from 'react'; import { isDefined } from 'twenty-ui'; type UseTextVariableEditorProps = { @@ -23,6 +24,8 @@ export const useTextVariableEditor = ({ defaultValue, onUpdate, }: UseTextVariableEditorProps) => { + const [isInitializing, setIsInitializing] = useState(true); + const editor = useEditor({ extensions: [ Document, @@ -45,8 +48,12 @@ export const useTextVariableEditor = ({ if (isDefined(defaultValue)) { initializeEditorContent(editor, defaultValue); } + setIsInitializing(false); }, onUpdate: ({ editor }) => { + if (isInitializing) { + return; + } onUpdate(editor); }, editorProps: { @@ -54,11 +61,10 @@ export const useTextVariableEditor = ({ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - const { state } = view; - const { tr } = state; - // Insert hard break using the view's state and dispatch if (multiline === true) { + const { state } = view; + const { tr } = state; const transaction = tr.replaceSelectionWith( state.schema.nodes.hardBreak.create(), );