From f1aee7fd1863d49ea989423102280539dfa97e77 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 8 Jan 2026 16:43:34 +0100 Subject: [PATCH] Add a CARD layout to the Field widget (#16995) Closes https://github.com/twentyhq/core-team-issues/issues/1943 ## Demo https://github.com/user-attachments/assets/ea86e1e6-6495-42b8-8dd3-e2bf7d295bab --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../RecordDetailRelationRecordsListItem.tsx | 41 +- .../DefaultCompanyRecordPageLayout.ts | 30 +- .../calendar/components/CalendarWidget.tsx | 1 + .../components/WidgetActionFieldEdit.tsx | 4 +- .../widgets/emails/components/EmailWidget.tsx | 1 + .../components/FieldRichTextWidget.tsx | 1 + .../widgets/field/components/FieldWidget.tsx | 33 +- .../field/components/FieldWidgetDisplay.tsx | 4 +- .../FieldWidgetMorphRelationCard.tsx | 170 ++++++ ....tsx => FieldWidgetMorphRelationField.tsx} | 7 +- .../components/FieldWidgetRelationCard.tsx | 169 ++++++ ...ation.tsx => FieldWidgetRelationField.tsx} | 7 +- .../__stories__/FieldWidget.stories.tsx | 512 +++++++++++++++++- ...Id.ts => generateFieldWidgetInstanceId.ts} | 2 +- .../fields/components/FieldsWidget.tsx | 3 +- .../widgets/files/components/FileWidget.tsx | 1 + .../iframe/components/IframeWidget.tsx | 1 + .../widgets/notes/components/NoteWidget.tsx | 1 + .../widgets/tasks/components/TaskWidget.tsx | 1 + .../timeline/components/TimelineWidget.tsx | 1 + .../components/WidgetCardContent.tsx | 10 +- 21 files changed, 949 insertions(+), 51 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationCard.tsx rename packages/twenty-front/src/modules/page-layout/widgets/field/components/{FieldWidgetMorphRelation.tsx => FieldWidgetMorphRelationField.tsx} (94%) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx rename packages/twenty-front/src/modules/page-layout/widgets/field/components/{FieldWidgetRelation.tsx => FieldWidgetRelationField.tsx} (95%) rename packages/twenty-front/src/modules/page-layout/widgets/field/utils/{getFieldWidgetInstanceId.ts => generateFieldWidgetInstanceId.ts} (85%) diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx index 698850d8b8..03e58347b6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem.tsx @@ -12,10 +12,7 @@ import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { RecordFieldList } from '@/object-record/record-field-list/components/RecordFieldList'; -import { - RecordFieldsScopeContextProvider, - useRecordFieldsScopeContextOrThrow, -} from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext'; +import { useRecordFieldsScopeContextOrThrow } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext'; import { RecordDetailRecordsListItemContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailRecordsListItemContainer'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; @@ -31,7 +28,6 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { t } from '@lingui/core/macro'; @@ -113,7 +109,6 @@ export const RecordDetailRelationRecordsListItem = ({ const { onSubmit } = useContext(FieldInputEventContext); const { openModal } = useModal(); - const { isInRightDrawer } = useIsInRightDrawerOrThrow(); const { relationType, objectMetadataNameSingular } = fieldDefinition.metadata as FieldRelationMetadata; @@ -156,7 +151,7 @@ export const RecordDetailRelationRecordsListItem = ({ relationObjectMetadataNameSingular === CoreObjectNameSingular.WorkspaceMember; - const dropdownInstanceId = `record-field-card-menu-${relationFieldMetadataId}-${relationRecord.id}`; + const dropdownInstanceId = `record-field-card-menu:${scopeInstanceId}:${relationFieldMetadataId}:${relationRecord.id}`; const { closeDropdown } = useCloseDropdown(); const isDropdownOpen = useRecoilComponentValue( @@ -199,6 +194,7 @@ export const RecordDetailRelationRecordsListItem = ({ relationFieldMetadataItem?.name ?? '', )]: null, }; + const handleDetach = () => { closeDropdown(dropdownInstanceId); @@ -243,12 +239,15 @@ export const RecordDetailRelationRecordsListItem = ({ return ( <> - + - + - - - + {createPortal( { const isRelationField = isFieldRelation(fieldDefinition) || isFieldMorphRelation(fieldDefinition); - const instanceId = getFieldWidgetInstanceId({ + const instanceId = generateFieldWidgetInstanceId({ widgetId: widget.id, recordId: targetRecord.id, fieldName: fieldMetadataItem.name, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/emails/components/EmailWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/emails/components/EmailWidget.tsx index 36d336beca..5883ccd04a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/emails/components/EmailWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/emails/components/EmailWidget.tsx @@ -5,6 +5,7 @@ import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDraw import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field-rich-text/components/FieldRichTextWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field-rich-text/components/FieldRichTextWidget.tsx index 21fca49cc2..66704194a0 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field-rich-text/components/FieldRichTextWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field-rich-text/components/FieldRichTextWidget.tsx @@ -3,6 +3,7 @@ import { FieldRichTextCard } from '@/ui/layout/show-page/components/FieldRichTex import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidget.tsx index cd18c58ebd..3c6ac6446f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidget.tsx @@ -9,8 +9,10 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { FieldWidgetDisplay } from '@/page-layout/widgets/field/components/FieldWidgetDisplay'; -import { FieldWidgetMorphRelation } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelation'; -import { FieldWidgetRelation } from '@/page-layout/widgets/field/components/FieldWidgetRelation'; +import { FieldWidgetMorphRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelationCard'; +import { FieldWidgetMorphRelationField } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelationField'; +import { FieldWidgetRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetRelationCard'; +import { FieldWidgetRelationField } from '@/page-layout/widgets/field/components/FieldWidgetRelationField'; import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; @@ -29,6 +31,7 @@ import { } from 'twenty-ui/layout'; const StyledContainer = styled.div` + box-sizing: border-box; width: 100%; `; @@ -107,9 +110,21 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => { labelWidth: 90, }); + const layout = widget.configuration.layout; + if (isFieldMorphRelation(fieldDefinition)) { + if (layout === 'CARD') { + return ( + + ); + } + return ( - { } if (isFieldRelation(fieldDefinition)) { + if (layout === 'CARD') { + return ( + + ); + } + return ( - ; + recordId: string; + isInRightDrawer: boolean; +}; + +export const FieldWidgetMorphRelationCard = ({ + fieldDefinition, + recordId, + isInRightDrawer, +}: FieldWidgetMorphRelationCardProps) => { + const widget = useCurrentWidget(); + + const [expandedItem, setExpandedItem] = useState(''); + const targetRecord = useTargetRecord(); + + const instanceId = generateFieldWidgetInstanceId({ + widgetId: widget.id, + recordId: targetRecord.id, + fieldName: fieldDefinition.metadata.fieldName, + isInRightDrawer, + }); + + const handleItemClick = (id: string) => + setExpandedItem(id === expandedItem ? '' : id); + + const fieldMetadata = fieldDefinition.metadata; + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const useUpdateOneObjectRecordMutation = () => { + const updateEntity = ({ + variables, + }: { + variables: { + where: { id: string }; + updateOneRecordInput: Record; + }; + }) => { + updateOneRecord?.({ + idToUpdate: variables.where.id, + updateOneRecordInput: variables.updateOneRecordInput, + }); + }; + return [updateEntity, { loading: false }] as [ + (params: any) => void, + { loading: boolean }, + ]; + }; + + const isRecordFieldReadOnly = useIsRecordFieldReadOnly({ + recordId: targetRecord.id, + fieldMetadataId: fieldDefinition.fieldMetadataId, + objectMetadataId: objectMetadataItem.id, + }); + + const persistField = usePersistField({ + objectMetadataItemId: objectMetadataItem.id, + }); + + const handleSubmit: FieldInputEvent = ({ newValue }) => { + persistField({ + recordId: targetRecord.id, + fieldDefinition, + valueToPersist: newValue, + }); + }; + + const recordsWithObjectNameSingular = + useGetMorphRelationRelatedRecordsWithObjectNameSingular({ + recordId, + morphRelations: fieldMetadata.morphRelations, + }); + + const validRecords = recordsWithObjectNameSingular.filter((item) => + isDefined(item.value), + ); + + if (validRecords.length === 0) { + return ( + + + + + + {t`No related records`} + + + + + ); + } + + return ( + + + + + + {validRecords.map((item) => ( + + + + + ))} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelation.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationField.tsx similarity index 94% rename from packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelation.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationField.tsx index a6bc20f524..dd6b3510ed 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelation.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelationField.tsx @@ -14,6 +14,7 @@ import { } from 'twenty-ui/layout'; const StyledContainer = styled.div` + box-sizing: border-box; padding: ${({ theme }) => theme.spacing(1)}; width: 100%; `; @@ -24,17 +25,17 @@ const StyledRelationChipsContainer = styled.div` flex-wrap: wrap; `; -type FieldWidgetMorphRelationProps = { +type FieldWidgetMorphRelationFieldProps = { fieldDefinition: FieldDefinition; recordId: string; isInRightDrawer: boolean; }; -export const FieldWidgetMorphRelation = ({ +export const FieldWidgetMorphRelationField = ({ fieldDefinition, recordId, isInRightDrawer, -}: FieldWidgetMorphRelationProps) => { +}: FieldWidgetMorphRelationFieldProps) => { const fieldMetadata = fieldDefinition.metadata; const recordsWithObjectNameSingular = 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 new file mode 100644 index 0000000000..3f0ca8b0ba --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationCard.tsx @@ -0,0 +1,169 @@ +import { Fragment, useState } from 'react'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; +import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext'; +import { RecordDetailRelationRecordsListItem } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem'; +import { RecordDetailRelationRecordsListItemEffect } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { + FieldInputEventContext, + type FieldInputEvent, +} from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +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 { 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 { t } from '@lingui/core/macro'; +import { isDefined } from 'twenty-shared/utils'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, +} from 'twenty-ui/layout'; + +type FieldWidgetRelationCardProps = { + fieldDefinition: FieldDefinition; + relationValue: any; + isInRightDrawer: boolean; +}; + +export const FieldWidgetRelationCard = ({ + fieldDefinition, + relationValue, + isInRightDrawer, +}: FieldWidgetRelationCardProps) => { + const widget = useCurrentWidget(); + + const [expandedItem, setExpandedItem] = useState(''); + const targetRecord = useTargetRecord(); + + const instanceId = generateFieldWidgetInstanceId({ + widgetId: widget.id, + recordId: targetRecord.id, + fieldName: fieldDefinition.metadata.fieldName, + isInRightDrawer, + }); + + const handleItemClick = (recordId: string) => + setExpandedItem(recordId === expandedItem ? '' : recordId); + + const fieldMetadata = fieldDefinition.metadata; + const relationObjectNameSingular = + fieldMetadata.relationObjectMetadataNameSingular; + const relationFieldMetadataId = fieldMetadata.relationFieldMetadataId; + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const useUpdateOneObjectRecordMutation = () => { + const updateEntity = ({ + variables, + }: { + variables: { + where: { id: string }; + updateOneRecordInput: Record; + }; + }) => { + updateOneRecord?.({ + idToUpdate: variables.where.id, + updateOneRecordInput: variables.updateOneRecordInput, + }); + }; + return [updateEntity, { loading: false }] as [ + (params: any) => void, + { loading: boolean }, + ]; + }; + + const isRecordFieldReadOnly = useIsRecordFieldReadOnly({ + recordId: targetRecord.id, + fieldMetadataId: fieldDefinition.fieldMetadataId, + objectMetadataId: objectMetadataItem.id, + }); + + const persistField = usePersistField({ + objectMetadataItemId: objectMetadataItem.id, + }); + + const handleSubmit: FieldInputEvent = ({ newValue }) => { + persistField({ + recordId: targetRecord.id, + fieldDefinition, + valueToPersist: newValue, + }); + }; + + const records = Array.isArray(relationValue) + ? relationValue + : isDefined(relationValue) + ? [relationValue] + : []; + + if (records.length === 0) { + return ( + + + + + + {t`No related records`} + + + + + ); + } + + return ( + + + + + {records.map((record) => ( + + + + + ))} + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelation.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationField.tsx similarity index 95% rename from packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelation.tsx rename to packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationField.tsx index 1a0b3287fc..503301d227 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelation.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationField.tsx @@ -14,6 +14,7 @@ import { } from 'twenty-ui/layout'; const StyledContainer = styled.div` + box-sizing: border-box; padding: ${({ theme }) => theme.spacing(1)}; width: 100%; `; @@ -24,17 +25,17 @@ const StyledRelationChipsContainer = styled.div` flex-wrap: wrap; `; -type FieldWidgetRelationProps = { +type FieldWidgetRelationFieldProps = { fieldDefinition: FieldDefinition; relationValue: any; isInRightDrawer: boolean; }; -export const FieldWidgetRelation = ({ +export const FieldWidgetRelationField = ({ fieldDefinition, relationValue, isInRightDrawer, -}: FieldWidgetRelationProps) => { +}: FieldWidgetRelationFieldProps) => { const fieldMetadata = fieldDefinition.metadata; const isOneToMany = fieldMetadata.relationType === 'ONE_TO_MANY'; const relationObjectNameSingular = 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 b61e5dd3bd..ecdc5b927d 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 @@ -4,7 +4,7 @@ import { useApolloClient, } from '@apollo/client'; import { type Meta, type StoryObj } from '@storybook/react'; -import { expect, within } from '@storybook/test'; +import { expect, userEvent, waitFor, within } from '@storybook/test'; import { MemoryRouter } from 'react-router-dom'; import { type MutableSnapshot } from 'recoil'; @@ -1551,8 +1551,516 @@ export const TimelineActivityRelationFieldWidget: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // MANY_TO_ONE relation from TimelineActivity to WorkspaceMember const workspaceMemberChip = await canvas.findByText('Sarah Johnson'); expect(workspaceMemberChip).toBeVisible(); }, }; + +export const ManyToOneRelationCardWidget: Story = { + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-relation-card', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'Account Owner', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 4, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: accountOwnerField.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), mockCompanyRecord); + if ( + mockCompanyRecord.accountOwner !== null && + mockCompanyRecord.accountOwner !== undefined + ) { + snapshot.set( + recordStoreFamilyState(mockCompanyRecord.accountOwner.id), + mockCompanyRecord.accountOwner, + ); + } + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const accountOwnerChip = await canvas.findByText('John Doe'); + expect(accountOwnerChip).toBeVisible(); + + const expandButton = await canvas.findByTestId('expand-button'); + await userEvent.click(expandButton); + + const lastUpdateField = await canvas.findByText('Last update'); + + await waitFor(() => { + expect(lastUpdateField).toBeVisible(); + }); + }, +}; + +export const OneToManyRelationCardWidget: Story = { + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-one-to-many-relation-card', + 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), mockCompanyRecord); + snapshot.set( + recordStoreFamilyState(TEST_PERSON_RECORD_ID), + mockPersonRecord, + ); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const personChip = await canvas.findByText('Jane Smith'); + expect(personChip).toBeVisible(); + }, +}; + +export const ManyToOneRelationCardWidgetEmpty: Story = { + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-relation-card-empty', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'Account Owner', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 4, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: accountOwnerField.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), { + ...mockCompanyRecord, + accountOwner: null, + }); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + const emptyPlaceholder = canvas.queryByText('No related records'); + expect(emptyPlaceholder).toBeInTheDocument(); + }); + }, +}; + +export const TimelineActivityRelationCardWidget: Story = { + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-timeline-activity-relation-card', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'Workspace Member', + objectMetadataId: timelineActivityObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 12, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: timelineActivityWorkspaceMemberField.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, + timelineActivityObjectMetadataItem.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_TIMELINE_ACTIVITY_RECORD_ID), + mockTimelineActivityRecord, + ); + snapshot.set( + recordStoreFamilyState('test-workspace-member-xyz'), + mockWorkspaceMemberRecord, + ); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const workspaceMemberChip = await canvas.findByText('Sarah Johnson'); + expect(workspaceMemberChip).toBeVisible(); + + const expandButton = await canvas.findByTestId('expand-button'); + await userEvent.click(expandButton); + + const lastUpdateField = await canvas.findByText('Last update'); + + await waitFor(() => { + expect(lastUpdateField).toBeVisible(); + }); + }, +}; + +export const TimelineActivityRelationCardWidgetEmpty: Story = { + render: () => { + const widget: PageLayoutWidget = { + __typename: 'PageLayoutWidget', + id: 'widget-timeline-activity-relation-card-empty', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELD, + title: 'Workspace Member', + objectMetadataId: timelineActivityObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 12, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + configurationType: WidgetConfigurationType.FIELD, + fieldMetadataId: timelineActivityWorkspaceMemberField.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, + timelineActivityObjectMetadataItem.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_TIMELINE_ACTIVITY_RECORD_ID), { + ...mockTimelineActivityRecord, + workspaceMember: null, + }); + }; + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const emptyPlaceholder = await canvas.findByText('No related records'); + + await waitFor(() => { + expect(emptyPlaceholder).toBeVisible(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/generateFieldWidgetInstanceId.ts similarity index 85% rename from packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts rename to packages/twenty-front/src/modules/page-layout/widgets/field/utils/generateFieldWidgetInstanceId.ts index 54d30e8780..51c3266549 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getFieldWidgetInstanceId.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/generateFieldWidgetInstanceId.ts @@ -1,4 +1,4 @@ -export const getFieldWidgetInstanceId = ({ +export const generateFieldWidgetInstanceId = ({ widgetId, recordId, fieldName, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx index 7908f7badc..4fdc93d957 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx @@ -36,6 +36,7 @@ import { } from 'twenty-ui/layout'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; @@ -49,7 +50,7 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => { const targetRecord = useTargetRecord(); const { isInRightDrawer } = useLayoutRenderingContext(); - const instanceId = `fields-widget-${widget.id}-${targetRecord.id}${isInRightDrawer ? '-right-drawer' : ''}`; + const instanceId = `${widget.id}-${targetRecord.id}${isInRightDrawer ? '-right-drawer' : ''}`; const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({ objectRecordId: targetRecord.id, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/files/components/FileWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/files/components/FileWidget.tsx index eb08134f4b..2c52155779 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/files/components/FileWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/files/components/FileWidget.tsx @@ -5,6 +5,7 @@ import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDraw import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/iframe/components/IframeWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/iframe/components/IframeWidget.tsx index 75e956e3a9..1e54a2ca8b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/iframe/components/IframeWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/iframe/components/IframeWidget.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div<{ $isEditMode: boolean }>` + box-sizing: border-box; border-radius: ${({ theme }) => theme.border.radius.md}; background: ${({ theme }) => theme.background.primary}; display: flex; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/notes/components/NoteWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/notes/components/NoteWidget.tsx index adc179f0d8..7f5b1ed247 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/notes/components/NoteWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/notes/components/NoteWidget.tsx @@ -5,6 +5,7 @@ import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDraw import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/tasks/components/TaskWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/tasks/components/TaskWidget.tsx index ddaa24d209..3996aee013 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/tasks/components/TaskWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/tasks/components/TaskWidget.tsx @@ -5,6 +5,7 @@ import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDraw import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/timeline/components/TimelineWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/timeline/components/TimelineWidget.tsx index 74b873224b..0a413805b4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/timeline/components/TimelineWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/timeline/components/TimelineWidget.tsx @@ -5,6 +5,7 @@ import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDraw import styled from '@emotion/styled'; const StyledContainer = styled.div` + box-sizing: border-box; display: flex; flex-direction: column; width: 100%; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx index e85cf4b775..23c8f44458 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx @@ -3,13 +3,11 @@ import styled from '@emotion/styled'; import { type WidgetCardVariant } from '~/modules/page-layout/widgets/types/WidgetCardVariant'; const StyledWidgetCardContent = styled.div<{ variant: WidgetCardVariant }>` - align-items: center; - display: flex; - height: 100%; - flex: 1; - overflow: hidden; - justify-content: center; box-sizing: border-box; + display: grid; + grid-template-columns: minmax(0, 1fr); + height: 100%; + overflow: hidden; ${({ theme, variant }) => { if (variant === 'dashboard') {