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)".
>
>
>
> ## 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;