From 4ce6119be7d444c7f9897b2b9f3eb9b5f67fce27 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Jan 2026 17:58:23 +0100 Subject: [PATCH] Add progressive loading to FIELD widget CARD display mode (#17202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/user-attachments/assets/3a444937-0290-4505-85e1-64b7b713bc3b - [x] Analyze the existing `FieldWidgetRelationCard` and `FieldWidgetMorphRelationCard` components - [x] Review existing patterns for "More" buttons (found `IconChevronDown` pattern) - [x] Create a reusable `FieldWidgetShowMoreButton` component for the "More (X)" button - [x] Update `FieldWidgetRelationCard` to show max 5 items initially with progressive loading - [x] Update `FieldWidgetMorphRelationCard` to show max 5 items initially with progressive loading - [x] Add Storybook story with play function to test progressive loading for regular relations - [x] Verify changes visually in Storybook (all tests pass) - [x] Run code review (no issues found) - [x] Run CodeQL security check (no code changes for CodeQL to analyze) - [x] Address PR review feedback: - [x] Move constants to separate files (`FieldWidgetRelationCardInitialVisibleItems.ts` and `FieldWidgetRelationCardLoadMoreIncrement.ts`) - [x] Add translation for "More (X)" string using `t` macro from `@lingui/core/macro` ## Summary This PR adds progressive loading functionality to the FIELD widget with CARD display mode: 1. **New Component**: Created `FieldWidgetShowMoreButton` - A styled button component that displays "More (X)" with a chevron icon, showing the remaining count of hidden items. 2. **Updated Components**: - `FieldWidgetRelationCard`: Now displays max 5 relation cards initially, with a "More (X)" button that loads 5 more items on each click - `FieldWidgetMorphRelationCard`: Same progressive loading behavior for morph relations 3. **Constants**: Moved to separate files following codebase conventions: - `FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS` (5) - `FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT` (5) 4. **Storybook Story**: Added `OneToManyRelationCardWidgetWithProgressiveLoading` story with play function that tests: - Initial display of 5 items - "More (7)" button visibility - Loading additional items on click - Button disappearing when all items are shown
Original prompt > > ---- > > *This section details on the original issue you should resolve* > > [RPL] Allow users to progressively load relations in FIELD widget with CARD display mode > ## Current state > > The FIELD widget should display max 5 relations in CARD display mode. > > ## Goal > > Display a "More (12)" button. Clicking on this button loads 5 more items. After a click, "More (12)" becomes "More (7)". > > Image > > ## Technical input > > `FieldWidgetRelationCard` already loads all the relations and maps over each one of them. Create a state that's updated when clicking on the More button, slicing the relations to display. > > ## Acceptance criteria > > - Must work for relations and morph relations in _card_ display mode > - Create stories to assert it works properly (with play function to actually test the components) > > ## Comments on the Issue (you are @copilot in this section) > > > >
- Fixes twentyhq/core-team-issues#2063 --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Devessier <29370468+Devessier@users.noreply.github.com> Co-authored-by: Baptiste Devessier --- .../FieldWidgetMorphRelationCard.tsx | 31 ++- .../components/FieldWidgetRelationCard.tsx | 31 ++- .../components/FieldWidgetShowMoreButton.tsx | 47 +++++ .../__stories__/FieldWidget.stories.tsx | 199 ++++++++++++++++++ ...ldWidgetRelationCardInitialVisibleItems.ts | 1 + ...ieldWidgetRelationCardLoadMoreIncrement.ts | 1 + 6 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetShowMoreButton.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationCard.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationCard.tsx index bc419984c84..d906607eccb 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationCard.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationCard.tsx @@ -1,3 +1,4 @@ +import styled from '@emotion/styled'; import { Fragment, useState } from 'react'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -16,12 +17,19 @@ import { import { usePersistField } from '@/object-record/record-field/ui/hooks/usePersistField'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { FieldWidgetShowMoreButton } from '@/page-layout/widgets/field/components/FieldWidgetShowMoreButton'; +import { FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems'; +import { FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement'; import { generateFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/generateFieldWidgetInstanceId'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; import { isDefined } from 'twenty-shared/utils'; +const StyledShowMoreButtonContainer = styled.div` + padding-top: ${({ theme }) => theme.spacing(2)}; +`; + type FieldWidgetMorphRelationCardProps = { fieldDefinition: FieldDefinition; recordId: string; @@ -36,6 +44,9 @@ export const FieldWidgetMorphRelationCard = ({ const widget = useCurrentWidget(); const [expandedItem, setExpandedItem] = useState(''); + const [visibleItemsCount, setVisibleItemsCount] = useState( + FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS, + ); const targetRecord = useTargetRecord(); const instanceId = generateFieldWidgetInstanceId({ @@ -48,6 +59,12 @@ export const FieldWidgetMorphRelationCard = ({ const handleItemClick = (id: string) => setExpandedItem(id === expandedItem ? '' : id); + const handleShowMore = () => { + setVisibleItemsCount( + (prevCount) => prevCount + FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT, + ); + }; + const fieldMetadata = fieldDefinition.metadata; const { objectMetadataItem } = useObjectMetadataItem({ @@ -110,6 +127,10 @@ export const FieldWidgetMorphRelationCard = ({ return null; } + const visibleRecords = validRecords.slice(0, visibleItemsCount); + const remainingCount = validRecords.length - visibleItemsCount; + const hasMoreRecords = remainingCount > 0; + return ( @@ -124,7 +145,7 @@ export const FieldWidgetMorphRelationCard = ({ > - {validRecords.map((item) => ( + {visibleRecords.map((item) => ( ))} + {hasMoreRecords && ( + + + + )} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx index 95375a8eaca..f6908c44adc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx @@ -1,3 +1,4 @@ +import styled from '@emotion/styled'; import { Fragment, useState } from 'react'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; @@ -14,12 +15,19 @@ import { import { usePersistField } from '@/object-record/record-field/ui/hooks/usePersistField'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { FieldWidgetShowMoreButton } from '@/page-layout/widgets/field/components/FieldWidgetShowMoreButton'; +import { FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems'; +import { FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement'; import { generateFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/generateFieldWidgetInstanceId'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; import { isDefined } from 'twenty-shared/utils'; +const StyledShowMoreButtonContainer = styled.div` + padding-top: ${({ theme }) => theme.spacing(2)}; +`; + type FieldWidgetRelationCardProps = { fieldDefinition: FieldDefinition; relationValue: any; @@ -34,6 +42,9 @@ export const FieldWidgetRelationCard = ({ const widget = useCurrentWidget(); const [expandedItem, setExpandedItem] = useState(''); + const [visibleItemsCount, setVisibleItemsCount] = useState( + FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS, + ); const targetRecord = useTargetRecord(); const instanceId = generateFieldWidgetInstanceId({ @@ -46,6 +57,12 @@ export const FieldWidgetRelationCard = ({ const handleItemClick = (recordId: string) => setExpandedItem(recordId === expandedItem ? '' : recordId); + const handleShowMore = () => { + setVisibleItemsCount( + (prevCount) => prevCount + FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT, + ); + }; + const fieldMetadata = fieldDefinition.metadata; const relationObjectNameSingular = fieldMetadata.relationObjectMetadataNameSingular; @@ -107,6 +124,10 @@ export const FieldWidgetRelationCard = ({ return null; } + const visibleRecords = records.slice(0, visibleItemsCount); + const remainingCount = records.length - visibleItemsCount; + const hasMoreRecords = remainingCount > 0; + return ( @@ -120,7 +141,7 @@ export const FieldWidgetRelationCard = ({ }} > - {records.map((record) => ( + {visibleRecords.map((record) => ( ))} + {hasMoreRecords && ( + + + + )} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetShowMoreButton.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetShowMoreButton.tsx new file mode 100644 index 00000000000..597277cc6a3 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetShowMoreButton.tsx @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; + +import { IconChevronDown } from 'twenty-ui/display'; + +type FieldWidgetShowMoreButtonProps = { + remainingCount: number; + onClick: () => void; +}; + +const StyledButton = styled.button` + display: flex; + width: 100%; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + height: 24px; + box-sizing: border-box; + padding: 0; + border: none; + background: transparent; + color: ${({ theme }) => theme.font.color.tertiary}; + font-family: ${({ theme }) => theme.font.family}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + cursor: pointer; + transition: color ${({ theme }) => theme.animation.duration.instant}s ease; + + &:hover { + color: ${({ theme }) => theme.font.color.primary}; + } +`; + +const StyledIcon = styled(IconChevronDown)` + height: 16px; + width: 16px; +`; + +export const FieldWidgetShowMoreButton = ({ + remainingCount, + onClick, +}: FieldWidgetShowMoreButtonProps) => { + return ( + + + {t`More (${remainingCount})`} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx index f5b80cb1b74..6e20ee1715f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx @@ -1867,3 +1867,202 @@ export const TimelineActivityRelationCardWidget: Story = { }); }, }; + +// Helper function to generate mock person records for progressive loading tests +const generateMockPersonRecords = (count: number) => { + const names = [ + { firstName: 'Jane', lastName: 'Smith' }, + { firstName: 'John', lastName: 'Williams' }, + { firstName: 'Alice', lastName: 'Brown' }, + { firstName: 'Bob', lastName: 'Davis' }, + { firstName: 'Carol', lastName: 'Miller' }, + { firstName: 'David', lastName: 'Wilson' }, + { firstName: 'Emma', lastName: 'Moore' }, + { firstName: 'Frank', lastName: 'Taylor' }, + { firstName: 'Grace', lastName: 'Anderson' }, + { firstName: 'Henry', lastName: 'Thomas' }, + { firstName: 'Ivy', lastName: 'Jackson' }, + { firstName: 'Jack', lastName: 'White' }, + ]; + + return Array.from({ length: count }, (_, index) => { + const nameInfo = names[index % names.length]; + const recordId = `person-${index + 1}`; + return { + id: recordId, + __typename: 'Person', + name: { + __typename: 'FullName', + firstName: nameInfo.firstName, + lastName: `${nameInfo.lastName} ${index + 1}`, + }, + emails: { + __typename: 'Emails', + primaryEmail: `${nameInfo.firstName.toLowerCase()}.${nameInfo.lastName.toLowerCase()}${index + 1}@example.com`, + additionalEmails: [], + }, + phones: { + __typename: 'Phones', + primaryPhoneNumber: `555000${(index + 1).toString().padStart(4, '0')}`, + primaryPhoneCountryCode: '+1', + primaryPhoneCallingCode: '+1', + additionalPhones: [], + }, + }; + }); +}; + +export const OneToManyRelationCardWidgetWithProgressiveLoading: Story = { + render: () => { + const mockPeople = generateMockPersonRecords(12); + const companyWithManyPeople = { + ...mockCompanyRecord, + people: mockPeople.map(({ id, __typename, name }) => ({ + __typename, + id, + name, + })), + }; + + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-one-to-many-relation-card-progressive', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'People', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 11, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: companyPeopleField.id, + layout: 'CARD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }; + + const initializeState = (snapshot: MutableSnapshot) => { + snapshot.set(objectMetadataItemsState, generatedMockObjectMetadataItems); + snapshot.set(shouldAppBeLoadingState, false); + const pageLayoutData = createPageLayoutWithWidget( + widget, + companyObjectMetadataItem.id, + ); + snapshot.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + snapshot.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + snapshot.set( + recordStoreFamilyState(TEST_RECORD_ID), + companyWithManyPeople, + ); + // Set each person record in the store + mockPeople.forEach((person) => { + snapshot.set(recordStoreFamilyState(person.id), person); + }); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Verify initial display - should show first 5 items + const firstPerson = await canvas.findByText('Jane Smith 1'); + expect(firstPerson).toBeVisible(); + + const fifthPerson = await canvas.findByText('Carol Miller 5'); + expect(fifthPerson).toBeVisible(); + + // Verify sixth person is NOT visible initially + expect(canvas.queryByText('David Wilson 6')).not.toBeInTheDocument(); + + // Verify "More (7)" button is visible (12 total - 5 shown = 7 remaining) + const moreButton = await canvas.findByTestId( + 'field-widget-show-more-button', + ); + expect(moreButton).toBeVisible(); + expect(moreButton).toHaveTextContent('More (7)'); + + // Click "More" button to load 5 more items + await userEvent.click(moreButton); + + // Verify more items are now visible + await waitFor(() => { + const sixthPerson = canvas.getByText('David Wilson 6'); + expect(sixthPerson).toBeVisible(); + }); + + const tenthPerson = await canvas.findByText('Henry Thomas 10'); + expect(tenthPerson).toBeVisible(); + + // Verify "More (2)" button is visible (12 total - 10 shown = 2 remaining) + const updatedMoreButton = await canvas.findByTestId( + 'field-widget-show-more-button', + ); + expect(updatedMoreButton).toHaveTextContent('More (2)'); + + // Click "More" button again to load remaining items + await userEvent.click(updatedMoreButton); + + // Verify all items are now visible + await waitFor(() => { + const twelfthPerson = canvas.getByText('Jack White 12'); + expect(twelfthPerson).toBeVisible(); + }); + + // Verify "More" button is no longer visible + expect( + canvas.queryByTestId('field-widget-show-more-button'), + ).not.toBeInTheDocument(); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems.ts new file mode 100644 index 00000000000..6b2c281d544 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems.ts @@ -0,0 +1 @@ +export const FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS = 5; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement.ts new file mode 100644 index 00000000000..96f32b86266 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement.ts @@ -0,0 +1 @@ +export const FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT = 5;