Add progressive loading to FIELD widget CARD display mode (#17202)

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

<!-- START COPILOT ORIGINAL PROMPT -->



<details>

<summary>Original prompt</summary>

> 
> ----
> 
> *This section details on the original issue you should resolve*
> 
> <issue_title>[RPL] Allow users to progressively load relations in
FIELD widget with CARD display mode</issue_title>
> <issue_description>## 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)".
> 
> <img width="1334" height="712" alt="Image"
src="https://github.com/user-attachments/assets/e801cca2-af99-4d87-8ce2-0c639775b0b1"
/>
> 
> ## 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)</issue_description>
> 
> ## Comments on the Issue (you are @copilot in this section)
> 
> <comments>
> </comments>
> 


</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes twentyhq/core-team-issues#2063

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 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 <baptiste@devessier.fr>
This commit is contained in:
Copilot
2026-01-19 16:58:23 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Devessier Baptiste Devessier
parent 5a5e9eeb9e
commit 4ce6119be7
6 changed files with 308 additions and 2 deletions
@@ -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<FieldMorphRelationMetadata>;
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 (
<RightDrawerProvider value={{ isInRightDrawer }}>
<RecordFieldsScopeContextProvider value={{ scopeInstanceId: instanceId }}>
@@ -124,7 +145,7 @@ export const FieldWidgetMorphRelationCard = ({
>
<FieldInputEventContext.Provider value={{ onSubmit: handleSubmit }}>
<RecordDetailRecordsListContainer>
{validRecords.map((item) => (
{visibleRecords.map((item) => (
<Fragment key={`${item.value.id}-${item.fieldMetadataId}`}>
<RecordDetailRelationRecordsListItemEffect
relationRecordId={item.value.id}
@@ -139,6 +160,14 @@ export const FieldWidgetMorphRelationCard = ({
/>
</Fragment>
))}
{hasMoreRecords && (
<StyledShowMoreButtonContainer>
<FieldWidgetShowMoreButton
remainingCount={remainingCount}
onClick={handleShowMore}
/>
</StyledShowMoreButtonContainer>
)}
</RecordDetailRecordsListContainer>
</FieldInputEventContext.Provider>
</FieldContext.Provider>
@@ -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<FieldRelationMetadata>;
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 (
<RightDrawerProvider value={{ isInRightDrawer }}>
<RecordFieldsScopeContextProvider value={{ scopeInstanceId: instanceId }}>
@@ -120,7 +141,7 @@ export const FieldWidgetRelationCard = ({
}}
>
<FieldInputEventContext.Provider value={{ onSubmit: handleSubmit }}>
{records.map((record) => (
{visibleRecords.map((record) => (
<Fragment key={record.id}>
<RecordDetailRelationRecordsListItemEffect
relationRecordId={record.id}
@@ -139,6 +160,14 @@ export const FieldWidgetRelationCard = ({
/>
</Fragment>
))}
{hasMoreRecords && (
<StyledShowMoreButtonContainer>
<FieldWidgetShowMoreButton
remainingCount={remainingCount}
onClick={handleShowMore}
/>
</StyledShowMoreButtonContainer>
)}
</FieldInputEventContext.Provider>
</FieldContext.Provider>
</RecordFieldsScopeContextProvider>
@@ -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 (
<StyledButton data-testid="field-widget-show-more-button" onClick={onClick}>
<StyledIcon />
{t`More (${remainingCount})`}
</StyledButton>
);
};
@@ -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 (
<div style={{ width: '400px', padding: '20px' }}>
<JestMetadataAndApolloMocksWrapper>
<CoreClientProviderWrapper>
<PageLayoutTestWrapper initializeState={initializeState}>
<LayoutRenderingProvider
value={{
isInRightDrawer: false,
layoutType: PageLayoutType.RECORD_PAGE,
targetRecordIdentifier: {
id: TEST_RECORD_ID,
targetObjectNameSingular:
companyObjectMetadataItem.nameSingular,
},
}}
>
<PageLayoutContentProvider
value={{
layoutMode: 'vertical-list',
tabId: 'fields',
}}
>
<WidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<FieldWidget widget={widget} />
</WidgetComponentInstanceContext.Provider>
</PageLayoutContentProvider>
</LayoutRenderingProvider>
</PageLayoutTestWrapper>
</CoreClientProviderWrapper>
</JestMetadataAndApolloMocksWrapper>
</div>
);
},
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();
},
};
@@ -0,0 +1 @@
export const FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS = 5;
@@ -0,0 +1 @@
export const FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT = 5;