diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index bf909eb037..4080b14f47 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4782,6 +4782,7 @@ export type WidgetConfiguration = AggregateChartConfiguration | BarChartConfigur export enum WidgetType { CALENDAR = 'CALENDAR', EMAILS = 'EMAILS', + FIELD = 'FIELD', FIELDS = 'FIELDS', FIELD_RICH_TEXT = 'FIELD_RICH_TEXT', FILES = 'FILES', diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 40a20211e6..78994f1ef5 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -4601,6 +4601,7 @@ export type WidgetConfiguration = AggregateChartConfiguration | BarChartConfigur export enum WidgetType { CALENDAR = 'CALENDAR', EMAILS = 'EMAILS', + FIELD = 'FIELD', FIELDS = 'FIELDS', FIELD_RICH_TEXT = 'FIELD_RICH_TEXT', FILES = 'FILES', diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isAggregateChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isAggregateChartConfiguration.ts index 534db9e8b6..33ef0b49ad 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isAggregateChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isAggregateChartConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type AggregateChartConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isAggregateChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is AggregateChartConfiguration => { return configuration?.__typename === 'AggregateChartConfiguration'; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarChartConfiguration.ts index cfae08a9e4..07efbffb80 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarChartConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type BarChartConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isBarChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is BarChartConfiguration => { return configuration?.__typename === 'BarChartConfiguration'; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarOrLineChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarOrLineChartConfiguration.ts index 6b24de287a..4b875c08cd 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarOrLineChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isBarOrLineChartConfiguration.ts @@ -4,12 +4,18 @@ import { type WidgetConfiguration, } from '~/generated/graphql'; +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { isBarChartConfiguration } from './isBarChartConfiguration'; import { isLineChartConfiguration } from './isLineChartConfiguration'; export const isBarOrLineChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is BarChartConfiguration | LineChartConfiguration => { return ( isBarChartConfiguration(configuration) || diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartConfiguration.ts index 336e50e1e6..b049404036 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isChartConfiguration.ts @@ -6,10 +6,16 @@ import { isBarChartConfiguration } from '@/command-menu/pages/page-layout/utils/ import { isGaugeChartConfiguration } from '@/command-menu/pages/page-layout/utils/isGaugeChartConfiguration'; import { isLineChartConfiguration } from '@/command-menu/pages/page-layout/utils/isLineChartConfiguration'; import { isPieChartConfiguration } from '@/command-menu/pages/page-layout/utils/isPieChartConfiguration'; +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; export const isChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is ChartConfiguration => { return ( isBarChartConfiguration(configuration) || diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isGaugeChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isGaugeChartConfiguration.ts index 4b0cd8cd8c..27821e23db 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isGaugeChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isGaugeChartConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type GaugeChartConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isGaugeChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is GaugeChartConfiguration => { return configuration?.__typename === 'GaugeChartConfiguration'; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isIframeConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isIframeConfiguration.ts index e2dda3ea11..a76c8761a0 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isIframeConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isIframeConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type IframeConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isIframeConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is IframeConfiguration => { return configuration?.__typename === 'IframeConfiguration'; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isLineChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isLineChartConfiguration.ts index 8e4336cc06..3ae16f6ac9 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isLineChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isLineChartConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type LineChartConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isLineChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is LineChartConfiguration => { return configuration?.__typename === 'LineChartConfiguration'; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isPieChartConfiguration.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isPieChartConfiguration.ts index e3157026b8..65c835f71d 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isPieChartConfiguration.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/isPieChartConfiguration.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type PieChartConfiguration, @@ -5,7 +6,12 @@ import { } from '~/generated/graphql'; export const isPieChartConfiguration = ( - configuration: WidgetConfiguration | FieldsConfiguration | null | undefined, + configuration: + | WidgetConfiguration + | FieldConfiguration + | FieldsConfiguration + | null + | undefined, ): configuration is PieChartConfiguration => { return configuration?.__typename === 'PieChartConfiguration'; }; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx index 0f2430fc28..5489771cc1 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal.tsx @@ -25,6 +25,7 @@ type RecordInlineCellAnchoredPortalProps = { recordId: string; instanceIdPrefix: string; children: React.ReactNode; + onCloseEditMode?: () => void; }; export const RecordInlineCellAnchoredPortal = ({ @@ -33,6 +34,7 @@ export const RecordInlineCellAnchoredPortal = ({ recordId, instanceIdPrefix, children, + onCloseEditMode, }: RecordInlineCellAnchoredPortalProps) => { const fieldInstanceId = getRecordFieldInputInstanceId({ recordId, @@ -87,6 +89,7 @@ export const RecordInlineCellAnchoredPortal = ({ useUpdateRecord: useUpdateOneObjectRecordMutation, isDisplayModeFixHeight: true, isRecordFieldReadOnly, + onCloseEditMode, }} > <> diff --git a/packages/twenty-front/src/modules/page-layout/constants/DefaultCompanyRecordPageLayout.ts b/packages/twenty-front/src/modules/page-layout/constants/DefaultCompanyRecordPageLayout.ts index 6519702c15..c1e3cf2474 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/DefaultCompanyRecordPageLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/DefaultCompanyRecordPageLayout.ts @@ -1,6 +1,6 @@ import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId'; import { type PageLayout } from '@/page-layout/types/PageLayout'; -import { PageLayoutType, WidgetType } from '~/generated/graphql'; +import { PageLayoutType, WidgetType } from '~/generated-metadata/graphql'; export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = { __typename: 'PageLayout', @@ -33,7 +33,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = { objectMetadataId: null, gridPosition: { __typename: 'GridPosition', - row: 0, + row: 3, column: 0, rowSpan: 12, columnSpan: 12, @@ -43,6 +43,144 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = { updatedAt: new Date().toISOString(), deletedAt: null, }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-account-owner', + pageLayoutTabId: 'company-tab-fields', + title: 'Account Owner', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'accountOwner', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-opportunities', + pageLayoutTabId: 'company-tab-fields', + title: 'Opportunities', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 1, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'opportunities', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-people', + pageLayoutTabId: 'company-tab-fields', + title: 'People', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'people', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-name', + pageLayoutTabId: 'company-tab-fields', + title: 'Name', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'name', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-employees', + pageLayoutTabId: 'company-tab-fields', + title: 'Employees', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'employees', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, + { + __typename: 'PageLayoutWidget', + id: 'company-widget-linkedin', + pageLayoutTabId: 'company-tab-fields', + title: 'LinkedIn', + type: WidgetType.FIELD, + objectMetadataId: null, + gridPosition: { + __typename: 'GridPosition', + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 12, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: 'linkedinLink', + layout: 'FIELD', + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }, ], }, { diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId.ts b/packages/twenty-front/src/modules/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId.ts new file mode 100644 index 0000000000..4a6a482315 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId.ts @@ -0,0 +1,31 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; +import { useMemo } from 'react'; + +export const useResolveFieldMetadataIdFromNameOrId = ( + fieldMetadataIdOrName: string, +): string | undefined => { + const targetRecord = useTargetRecord(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + return useMemo(() => { + // Try to find field by name first + const fieldByName = objectMetadataItem.fields.find( + (field) => field.name === fieldMetadataIdOrName, + ); + + if (fieldByName !== undefined) { + return fieldByName.id; + } + + // If not found by name, check if it's already an ID + const fieldById = objectMetadataItem.fields.find( + (field) => field.id === fieldMetadataIdOrName, + ); + + return fieldById?.id; + }, [objectMetadataItem.fields, fieldMetadataIdOrName]); +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/FieldConfiguration.ts b/packages/twenty-front/src/modules/page-layout/types/FieldConfiguration.ts new file mode 100644 index 0000000000..893ae46abb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/types/FieldConfiguration.ts @@ -0,0 +1,5 @@ +export type FieldConfiguration = { + __typename: 'FieldConfiguration'; + fieldMetadataId: string; + layout: 'FIELD' | 'CARD' | 'VIEW'; +}; diff --git a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidget.ts b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidget.ts index 1dec182eb1..be0ac77a54 100644 --- a/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/types/PageLayoutWidget.ts @@ -1,3 +1,4 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration'; import { type RulesLogic } from 'json-logic-js'; import { type Nullable } from 'twenty-shared/types'; @@ -12,5 +13,9 @@ export type PageLayoutWidget = Omit< > & { objectMetadataId?: Nullable; conditionalDisplay?: RulesLogic; - configuration?: WidgetConfiguration | FieldsConfiguration | null; + configuration?: + | WidgetConfiguration + | FieldsConfiguration + | FieldConfiguration + | null; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx index 8da71df77a..d8a5bebcd1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContentRenderer.tsx @@ -2,6 +2,7 @@ import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { CalendarWidget } from '@/page-layout/widgets/calendar/components/CalendarWidget'; import { EmailWidget } from '@/page-layout/widgets/emails/components/EmailWidget'; import { FieldRichTextWidgetRenderer } from '@/page-layout/widgets/field-rich-text/components/FieldRichTextWidgetRenderer'; +import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget'; import { FieldsWidget } from '@/page-layout/widgets/fields/components/FieldsWidget'; import { FileWidget } from '@/page-layout/widgets/files/components/FileWidget'; import { GraphWidgetRenderer } from '@/page-layout/widgets/graph/components/GraphWidgetRenderer'; @@ -29,6 +30,9 @@ export const WidgetContentRenderer = ({ case WidgetType.IFRAME: return ; + case WidgetType.FIELD: + return ; + case WidgetType.FIELDS: return ; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx index 5e97ab77d4..6e672ec501 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx @@ -66,8 +66,7 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => { const isRichTextWidget = widget.type === WidgetType.STANDALONE_RICH_TEXT; const hideRichTextHeader = isRichTextWidget && !isPageLayoutInEditMode; - const showHeader = - layoutMode !== 'canvas' && !isInPinnedTab && !hideRichTextHeader; + const showHeader = layoutMode !== 'canvas' && !hideRichTextHeader; const handleClick = () => { handleEditWidget({ 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 new file mode 100644 index 0000000000..e9163f8293 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidget.tsx @@ -0,0 +1,161 @@ +import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; +import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader'; +import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +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 { FieldWidgetComponentInstanceContext } from '@/page-layout/widgets/field/states/contexts/FieldWidgetComponentInstanceContext'; +import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow'; +import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; +import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, +} from 'twenty-ui/layout'; + +const StyledContainer = styled.div` + padding: ${({ theme }) => theme.spacing(1)}; + width: 100%; +`; + +type FieldWidgetProps = { + widget: PageLayoutWidget; +}; + +export const FieldWidget = ({ widget }: FieldWidgetProps) => { + assertFieldWidgetOrThrow(widget); + + const targetRecord = useTargetRecord(); + const { isInRightDrawer } = useLayoutRenderingContext(); + + const { isPrefetchLoading } = useRecordShowContainerData({ + objectRecordId: targetRecord.id, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const fieldMetadataId = widget.configuration.fieldMetadataId; + + const resolvedFieldMetadataId = + useResolveFieldMetadataIdFromNameOrId(fieldMetadataId); + + const { fieldMetadataItem } = useFieldMetadataItemById( + resolvedFieldMetadataId ?? '', + ); + + const record = useRecoilValue( + recordStoreFamilySelector({ + recordId: targetRecord.id, + fieldName: fieldMetadataItem?.name ?? '', + }), + ); + + if (isPrefetchLoading) { + return ( + + + + + + + + ); + } + + if (!isDefined(fieldMetadataItem) || !fieldMetadataItem.isActive) { + return ( + + + + + + + + {t`No field configured`} + + + {t`Select a field to display in this widget`} + + + + + + + ); + } + + const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({ + field: fieldMetadataItem, + position: 0, + objectMetadataItem, + showLabel: true, + labelWidth: 90, + }); + + if (isFieldMorphRelation(fieldDefinition)) { + return ( + + + + ); + } + + if (isFieldRelation(fieldDefinition)) { + return ( + + + + ); + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellEditModePortal.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellEditModePortal.tsx new file mode 100644 index 0000000000..dfcb645003 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellEditModePortal.tsx @@ -0,0 +1,61 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; +import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal'; +import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode'; +import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; +import { FieldWidgetInputContextProvider } from '@/page-layout/widgets/field/components/FieldWidgetInputContextProvider'; +import { fieldWidgetHoverComponentState } from '@/page-layout/widgets/field/states/fieldWidgetHoverComponentState'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useRecoilValue } from 'recoil'; + +type FieldWidgetCellEditModePortalProps = { + objectMetadataItem: ObjectMetadataItem; + fieldMetadataItem: FieldMetadataItem; + recordId: string; + instanceId: string; +}; + +export const FieldWidgetCellEditModePortal = ({ + objectMetadataItem, + fieldMetadataItem, + recordId, + instanceId, +}: FieldWidgetCellEditModePortalProps) => { + const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState); + const expectedDropdownFocusId = getDropdownFocusIdForRecordField( + recordId, + fieldMetadataItem.id, + 'inline-cell', + ); + const isEditing = activeDropdownFocusId === expectedDropdownFocusId; + + const setIsHovered = useSetRecoilComponentState( + fieldWidgetHoverComponentState, + ); + + const handleCloseEditMode = () => { + setIsHovered(false); + }; + + if (!isEditing) { + return null; + } + + return ( + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredContent.tsx new file mode 100644 index 0000000000..f765de370e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredContent.tsx @@ -0,0 +1,49 @@ +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode'; +import { RecordInlineCellHoveredPortalContent } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent'; +import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; +import { useOpenFieldWidgetFieldInputEditMode } from '@/page-layout/widgets/field/hooks/useOpenFieldWidgetFieldInputEditMode'; +import { useContext } from 'react'; + +type FieldWidgetCellHoveredContentProps = { + onMouseLeave: () => void; +}; + +export const FieldWidgetCellHoveredContent = ({ + onMouseLeave, +}: FieldWidgetCellHoveredContentProps) => { + const { editModeContentOnly, isCentered } = useRecordInlineCellContext(); + const { isRecordFieldReadOnly, recordId, fieldDefinition } = + useContext(FieldContext); + + const { openInlineCell } = useInlineCell(); + const { openFieldInput } = useOpenFieldWidgetFieldInputEditMode(); + + const shouldContainerBeClickable = + !isRecordFieldReadOnly && !editModeContentOnly; + + const handleClick = () => { + if (shouldContainerBeClickable) { + openInlineCell(); + openFieldInput({ + fieldDefinition, + recordId, + }); + } + }; + + return ( + + + {editModeContentOnly ? : } + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredPortal.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredPortal.tsx new file mode 100644 index 0000000000..87342ac7ba --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetCellHoveredPortal.tsx @@ -0,0 +1,48 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal'; +import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; +import { FieldWidgetCellHoveredContent } from '@/page-layout/widgets/field/components/FieldWidgetCellHoveredContent'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; +import { useRecoilValue } from 'recoil'; + +type FieldWidgetCellHoveredPortalProps = { + objectMetadataItem: ObjectMetadataItem; + fieldMetadataItem: FieldMetadataItem; + recordId: string; + instanceId: string; + isHovered: boolean; + onMouseLeave: () => void; +}; + +export const FieldWidgetCellHoveredPortal = ({ + objectMetadataItem, + fieldMetadataItem, + recordId, + instanceId, + isHovered, + onMouseLeave, +}: FieldWidgetCellHoveredPortalProps) => { + const activeDropdownFocusId = useRecoilValue(activeDropdownFocusIdState); + const expectedDropdownFocusId = getDropdownFocusIdForRecordField( + recordId, + fieldMetadataItem.id, + 'inline-cell', + ); + const isEditing = activeDropdownFocusId === expectedDropdownFocusId; + + if (!isHovered || isEditing) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx new file mode 100644 index 0000000000..2b35e599eb --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetDisplay.tsx @@ -0,0 +1,122 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { FieldWidgetCellEditModePortal } from '@/page-layout/widgets/field/components/FieldWidgetCellEditModePortal'; +import { FieldWidgetCellHoveredPortal } from '@/page-layout/widgets/field/components/FieldWidgetCellHoveredPortal'; +import { FieldWidgetInlineCell } from '@/page-layout/widgets/field/components/FieldWidgetInlineCell'; +import { fieldWidgetHoverComponentState } from '@/page-layout/widgets/field/states/fieldWidgetHoverComponentState'; +import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; +import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + padding: ${({ theme }) => theme.spacing(1)}; + width: 100%; +`; + +type FieldWidgetDisplayProps = { + fieldDefinition: FieldDefinition; + fieldMetadataItem: FieldMetadataItem; + objectMetadataItem: ObjectMetadataItem; + recordId: string; + isInRightDrawer: boolean; +}; + +export const FieldWidgetDisplay = ({ + fieldDefinition, + fieldMetadataItem, + objectMetadataItem, + recordId, + isInRightDrawer, +}: FieldWidgetDisplayProps) => { + const [isHovered, setIsHovered] = useRecoilComponentState( + fieldWidgetHoverComponentState, + ); + + const instanceId = `field-widget-${recordId}-${fieldMetadataItem.name}-${isInRightDrawer ? 'right-drawer' : ''}`; + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ + objectNameSingular: objectMetadataItem.nameSingular, + objectRecordId: recordId, + }); + + const isRecordReadOnly = useIsRecordReadOnly({ + recordId, + objectMetadataId: objectMetadataItem.id, + }); + + const handleMouseEnter = () => setIsHovered(true); + const handleMouseLeave = () => setIsHovered(false); + + return ( + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCell.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCell.tsx new file mode 100644 index 0000000000..a4c3aad46e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCell.tsx @@ -0,0 +1,203 @@ +import { useCallback, useContext } from 'react'; + +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { FieldFocusContextProvider } from '@/object-record/record-field/ui/contexts/FieldFocusContextProvider'; +import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon'; + +import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly'; +import { useOpenFieldInputEditMode } from '@/object-record/record-field/ui/hooks/useOpenFieldInputEditMode'; + +import { + FieldInputEventContext, + type FieldInputClickOutsideEvent, + type FieldInputEvent, +} from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/ui/hooks/usePersistFieldFromFieldInputContext'; +import { + RecordInlineCellContext, + type RecordInlineCellContextProps, +} from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { getDropdownFocusIdForRecordField } from '@/object-record/utils/getDropdownFocusIdForRecordField'; +import { FieldWidgetInlineCellContainer } from '@/page-layout/widgets/field/components/FieldWidgetInlineCellContainer'; +import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId'; +import { activeDropdownFocusIdState } from '@/ui/layout/dropdown/states/activeDropdownFocusIdState'; +import { useRecoilCallback } from 'recoil'; + +type FieldWidgetInlineCellProps = { + loading?: boolean; + instanceIdPrefix?: string; +}; + +export const FieldWidgetInlineCell = ({ + loading, + instanceIdPrefix, +}: FieldWidgetInlineCellProps) => { + const { + fieldDefinition, + recordId, + isCentered, + isDisplayModeFixHeight, + onOpenEditMode: onOpenEditModeFromContext, + onCloseEditMode: onCloseEditModeFromContext, + isRecordFieldReadOnly: isReadOnly, + } = useContext(FieldContext); + + const { openFieldInput, closeFieldInput } = useOpenFieldInputEditMode(); + + const onOpenEditMode = onOpenEditModeFromContext + ? onOpenEditModeFromContext + : () => + openFieldInput({ + fieldDefinition, + recordId, + prefix: instanceIdPrefix, + }); + + const onCloseEditMode = useCallback(() => { + onCloseEditModeFromContext + ? onCloseEditModeFromContext() + : closeFieldInput({ + fieldDefinition, + recordId, + prefix: instanceIdPrefix, + }); + }, [ + onCloseEditModeFromContext, + closeFieldInput, + fieldDefinition, + recordId, + instanceIdPrefix, + ]); + + const buttonIcon = useGetButtonIcon(); + + const isFieldInputOnly = useIsFieldInputOnly(); + + const { goBackToPreviousDropdownFocusId } = + useGoBackToPreviousDropdownFocusId(); + + const closeInlineCell = useCallback(() => { + onCloseEditMode(); + goBackToPreviousDropdownFocusId(); + }, [onCloseEditMode, goBackToPreviousDropdownFocusId]); + + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleCancel = () => { + closeInlineCell(); + }; + + const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleClickOutside = useRecoilCallback( + ({ snapshot }) => + ({ + event, + newValue, + skipPersist, + }: Parameters[0]) => { + const currentDropdownFocusId = snapshot + .getLoadable(activeDropdownFocusIdState) + .getValue(); + + const expectedDropdownFocusId = getDropdownFocusIdForRecordField( + recordId, + fieldDefinition.fieldMetadataId, + 'inline-cell', + ); + + if (currentDropdownFocusId !== expectedDropdownFocusId) { + return; + } + + event?.preventDefault(); + event?.stopImmediatePropagation(); + + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }, + [ + closeInlineCell, + recordId, + fieldDefinition.fieldMetadataId, + persistFieldFromFieldInputContext, + ], + ); + + const RecordInlineCellContextValue: RecordInlineCellContextProps = { + readonly: isReadOnly, + buttonIcon: buttonIcon, + editModeContent: , + displayModeContent: , + isDisplayModeFixHeight: isDisplayModeFixHeight, + editModeContentOnly: isFieldInputOnly, + loading: loading, + isCentered, + onOpenEditMode, + onCloseEditMode, + }; + + return ( + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellContainer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellContainer.tsx new file mode 100644 index 0000000000..da1d1ca508 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellContainer.tsx @@ -0,0 +1,59 @@ +import styled from '@emotion/styled'; +import { useContext } from 'react'; + +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus'; +import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { FieldWidgetInlineCellValue } from '@/page-layout/widgets/field/components/FieldWidgetInlineCellValue'; + +const StyledValueContainer = styled.div<{ readonly: boolean }>` + display: flex; + min-width: 0; + position: relative; + width: 100%; +`; + +const StyledInlineCellBaseContainer = styled.div<{ readonly: boolean }>` + box-sizing: border-box; + width: 100%; + display: flex; + height: fit-content; + gap: ${({ theme }) => theme.spacing(1)}; + user-select: none; + align-items: center; + cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')}; +`; + +export const FieldWidgetInlineCellContainer = () => { + const { readonly } = useRecordInlineCellContext(); + + const { onMouseEnter, onMouseLeave, anchorId } = useContext(FieldContext); + + const { setIsFocused } = useFieldFocus(); + + const handleContainerMouseEnter = () => { + if (!readonly) { + setIsFocused(true); + } + onMouseEnter?.(); + }; + + const handleContainerMouseLeave = () => { + if (!readonly) { + setIsFocused(false); + } + onMouseLeave?.(); + }; + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellValue.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellValue.tsx new file mode 100644 index 0000000000..3985ddfd6f --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInlineCellValue.tsx @@ -0,0 +1,45 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode'; +import { RecordInlineCellSkeletonLoader } from '@/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader'; + +const StyledClickableContainer = styled.div<{ + readonly?: boolean; + isCentered?: boolean; +}>` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + width: 100%; + + ${({ isCentered }) => + isCentered === true && + ` + justify-content: center; + `}; + + ${({ readonly }) => + !readonly && + css` + cursor: pointer; + `}; +`; + +export const FieldWidgetInlineCellValue = () => { + const { readonly, loading, isCentered } = useRecordInlineCellContext(); + + if (loading === true) { + return ; + } + + return ( + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInputContextProvider.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInputContextProvider.tsx new file mode 100644 index 0000000000..7616f95d77 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetInputContextProvider.tsx @@ -0,0 +1,110 @@ +import { + FieldInputEventContext, + type FieldInputClickOutsideEvent, + type FieldInputEvent, +} from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/ui/hooks/usePersistFieldFromFieldInputContext'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId'; +import { useRecoilCallback } from 'recoil'; + +type FieldWidgetInputContextProviderProps = { + children: React.ReactNode; +}; + +export const FieldWidgetInputContextProvider = ({ + children, +}: FieldWidgetInputContextProviderProps) => { + const { closeInlineCell } = useInlineCell(); + + const instanceId = useAvailableComponentInstanceId( + RecordFieldComponentInstanceContext, + ); + + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleCancel = () => { + closeInlineCell(); + }; + + const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( + ({ snapshot }) => + ({ newValue, event, skipPersist }) => { + const currentFocusId = snapshot + .getLoadable(currentFocusIdSelector) + .getValue(); + + if (currentFocusId !== instanceId) { + return; + } + event?.preventDefault(); + event?.stopImmediatePropagation(); + + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }, + [closeInlineCell, instanceId, persistFieldFromFieldInputContext], + ); + + const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + closeInlineCell(); + }; + + return ( + + {children} + + ); +}; 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/FieldWidgetMorphRelation.tsx new file mode 100644 index 0000000000..785d6f149e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetMorphRelation.tsx @@ -0,0 +1,79 @@ +import { RecordChip } from '@/object-record/components/RecordChip'; +import { useGetMorphRelationRelatedRecordsWithObjectNameSingular } from '@/object-record/record-field-list/record-detail-section/relation/components/hooks/useGetMorphRelationRelatedRecordsWithObjectNameSingular'; +import { type FieldMorphRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; +import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, +} from 'twenty-ui/layout'; + +const StyledContainer = styled.div` + padding: ${({ theme }) => theme.spacing(1)}; + width: 100%; +`; + +const StyledRelationChipsContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + flex-wrap: wrap; +`; + +type FieldWidgetMorphRelationProps = { + fieldMetadata: FieldMorphRelationMetadata; + recordId: string; + isInRightDrawer: boolean; +}; + +export const FieldWidgetMorphRelation = ({ + fieldMetadata, + recordId, + isInRightDrawer, +}: FieldWidgetMorphRelationProps) => { + const recordsWithObjectNameSingular = + useGetMorphRelationRelatedRecordsWithObjectNameSingular({ + recordId, + morphRelations: fieldMetadata.morphRelations, + }); + + // Render all related records from all morph targets + if (recordsWithObjectNameSingular.length > 0) { + return ( + + + + {recordsWithObjectNameSingular.map((morphItem, index) => ( + + ))} + + + + ); + } + + return ( + + + + + + + {t`No related records`} + + + + + + ); +}; 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/FieldWidgetRelation.tsx new file mode 100644 index 0000000000..d5d2e2963c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelation.tsx @@ -0,0 +1,89 @@ +import { RecordChip } from '@/object-record/components/RecordChip'; +import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; +import styled from '@emotion/styled'; +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'; + +const StyledContainer = styled.div` + padding: ${({ theme }) => theme.spacing(1)}; + width: 100%; +`; + +const StyledRelationChipsContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + flex-wrap: wrap; +`; + +type FieldWidgetRelationProps = { + fieldMetadata: FieldRelationMetadata; + relationValue: any; + isInRightDrawer: boolean; +}; + +export const FieldWidgetRelation = ({ + fieldMetadata, + relationValue, + isInRightDrawer, +}: FieldWidgetRelationProps) => { + const isOneToMany = fieldMetadata.relationType === 'ONE_TO_MANY'; + const relationObjectNameSingular = + fieldMetadata.relationObjectMetadataNameSingular; + + if (isOneToMany && Array.isArray(relationValue)) { + return ( + + + + {relationValue.map((relatedRecord: any) => ( + + ))} + + + + ); + } + + if (!isOneToMany && isDefined(relationValue)) { + return ( + + + + + + ); + } + + return ( + + + + + + + {t`No related records`} + + + + + + ); +}; 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 new file mode 100644 index 0000000000..29125a642d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/__stories__/FieldWidget.stories.tsx @@ -0,0 +1,923 @@ +import { type Meta, type StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; +import { MemoryRouter } from 'react-router-dom'; +import { type MutableSnapshot } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { shouldAppBeLoadingState } from '@/object-metadata/states/shouldAppBeLoadingState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper'; +import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget'; +import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow'; +import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { PageLayoutType, WidgetType } from '~/generated-metadata/graphql'; +import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; +import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; +import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow( + CoreObjectNameSingular.Company, +); + +const personObjectMetadataItem = getMockObjectMetadataItemOrThrow( + CoreObjectNameSingular.Person, +); + +const opportunityObjectMetadataItem = getMockObjectMetadataItemOrThrow( + CoreObjectNameSingular.Opportunity, +); + +const timelineActivityObjectMetadataItem = getMockObjectMetadataItemOrThrow( + CoreObjectNameSingular.TimelineActivity, +); + +const nameField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'name', +}); + +const addressField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'address', +}); + +const employeesField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'employees', +}); + +const linkedinField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'linkedinLink', +}); + +const accountOwnerField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'accountOwner', +}); + +const idealCustomerProfileField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'idealCustomerProfile', +}); + +const annualRecurringRevenueField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'annualRecurringRevenue', +}); + +const personEmailsField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: personObjectMetadataItem, + fieldName: 'emails', +}); + +const personPhonesField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: personObjectMetadataItem, + fieldName: 'phones', +}); + +const opportunityStageField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: opportunityObjectMetadataItem, + fieldName: 'stage', +}); + +const companyWorkPolicyField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'workPolicy', +}); + +const companyPeopleField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'people', +}); + +const timelineActivityWorkspaceMemberField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: timelineActivityObjectMetadataItem, + fieldName: 'workspaceMember', +}); + +const TEST_RECORD_ID = 'test-record-123'; +const TEST_PERSON_RECORD_ID = 'test-person-456'; +const TEST_OPPORTUNITY_RECORD_ID = 'test-opportunity-789'; +const TEST_TIMELINE_ACTIVITY_RECORD_ID = 'test-timeline-def'; + +const mockPersonRecord: ObjectRecord = { + __typename: 'Person', + id: TEST_PERSON_RECORD_ID, + name: { + __typename: 'FullName', + firstName: 'Jane', + lastName: 'Smith', + }, + emails: { + __typename: 'Emails', + primaryEmail: 'jane.smith@acme.com', + additionalEmails: ['jane@personal.com'], + }, + phones: { + __typename: 'Phones', + primaryPhoneNumber: '5551234567', + primaryPhoneCountryCode: '+1', + primaryPhoneCallingCode: '+1', + additionalPhones: [], + }, +}; + +const mockOpportunityRecord: ObjectRecord = { + __typename: 'Opportunity', + id: TEST_OPPORTUNITY_RECORD_ID, + name: 'Enterprise Deal', + stage: 'PROPOSAL', + amount: { + __typename: 'Currency', + amountMicros: 500000000000, + currencyCode: 'USD', + }, + closeDate: '2025-12-31T00:00:00Z', +}; + +const mockWorkspaceMemberRecord: ObjectRecord = { + __typename: 'WorkspaceMember', + id: 'test-workspace-member-xyz', + name: { + __typename: 'FullName', + firstName: 'Sarah', + lastName: 'Johnson', + }, + avatarUrl: '', + userEmail: 'sarah.johnson@acme.com', + colorScheme: 'Light', + locale: 'en', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + userId: 'test-user-xyz', +}; + +const mockTimelineActivityRecord: ObjectRecord = { + __typename: 'TimelineActivity', + id: TEST_TIMELINE_ACTIVITY_RECORD_ID, + name: 'Note created', + workspaceMember: { + __typename: 'WorkspaceMember', + id: 'test-workspace-member-xyz', + name: { + __typename: 'FullName', + firstName: 'Sarah', + lastName: 'Johnson', + }, + avatarUrl: '', + userEmail: 'sarah.johnson@acme.com', + colorScheme: 'Light', + locale: 'en', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + userId: 'test-user-xyz', + }, + happenedAt: '2025-01-15T10:30:00Z', +}; + +const mockCompanyRecord: ObjectRecord = { + __typename: 'Company', + id: TEST_RECORD_ID, + name: 'Acme Corporation', + address: { + addressStreet1: '123 Business St', + addressStreet2: null, + addressCity: 'San Francisco', + addressState: 'CA', + addressPostcode: '94102', + addressCountry: 'United States', + addressLat: null, + addressLng: null, + }, + employees: 250, + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/acme', + primaryLinkLabel: null, + secondaryLinks: null, + }, + idealCustomerProfile: true, + annualRecurringRevenue: { + __typename: 'Currency', + amountMicros: 5000000000000, + currencyCode: 'USD', + }, + workPolicy: ['ON_SITE', 'HYBRID'], + people: [ + { + __typename: 'Person', + id: TEST_PERSON_RECORD_ID, + name: { + __typename: 'FullName', + firstName: 'Jane', + lastName: 'Smith', + }, + }, + ], + accountOwner: { + __typename: 'WorkspaceMember', + id: '20202020-0687-4c41-b707-ed1bfca972a7', + name: { + __typename: 'FullName', + firstName: 'John', + lastName: 'Doe', + }, + avatarUrl: '', + userEmail: 'john.doe@acme.com', + colorScheme: 'Light', + locale: 'en', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + userId: '20202020-9e3b-46d4-a556-88b9ddc2b034', + }, +}; + +const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +const meta: Meta = { + title: 'Modules/PageLayout/Widgets/FieldWidget', + component: FieldWidget, + decorators: [ + ComponentDecorator, + ChipGeneratorsDecorator, + I18nFrontDecorator, + (Story, context) => { + assertFieldWidgetOrThrow(context.args.widget); + + const fieldMetadataId = context.args.widget.configuration.fieldMetadataId; + const fieldMetadataItem = [ + nameField, + addressField, + employeesField, + linkedinField, + accountOwnerField, + idealCustomerProfileField, + annualRecurringRevenueField, + personEmailsField, + personPhonesField, + opportunityStageField, + companyWorkPolicyField, + companyPeopleField, + timelineActivityWorkspaceMemberField, + ].find((field) => field.id === fieldMetadataId); + + // Determine which object and record to use based on the field + const isPersonField = [personEmailsField, personPhonesField].some( + (field) => field.id === fieldMetadataId, + ); + const isOpportunityField = [opportunityStageField].some( + (field) => field.id === fieldMetadataId, + ); + const isTimelineActivityField = [ + timelineActivityWorkspaceMemberField, + ].some((field) => field.id === fieldMetadataId); + const objectMetadataItem = isPersonField + ? personObjectMetadataItem + : isOpportunityField + ? opportunityObjectMetadataItem + : isTimelineActivityField + ? timelineActivityObjectMetadataItem + : companyObjectMetadataItem; + const mockRecord = isPersonField + ? mockPersonRecord + : isOpportunityField + ? mockOpportunityRecord + : isTimelineActivityField + ? mockTimelineActivityRecord + : mockCompanyRecord; + const recordId = isPersonField + ? TEST_PERSON_RECORD_ID + : isOpportunityField + ? TEST_OPPORTUNITY_RECORD_ID + : isTimelineActivityField + ? TEST_TIMELINE_ACTIVITY_RECORD_ID + : TEST_RECORD_ID; + + const initializeState = (snapshot: MutableSnapshot) => { + snapshot.set( + objectMetadataItemsState, + generatedMockObjectMetadataItems, + ); + snapshot.set(shouldAppBeLoadingState, false); + snapshot.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + { + id: PAGE_LAYOUT_TEST_INSTANCE_ID, + name: 'Mock Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: objectMetadataItem.id, + tabs: [], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }, + ); + + // Set the record with the appropriate field value + snapshot.set(recordStoreFamilyState(recordId), { + ...mockRecord, + [fieldMetadataItem?.name ?? '']: + mockRecord[fieldMetadataItem?.name as keyof typeof mockRecord], + }); + + // Set the related WorkspaceMember record for relation field display + if ( + !isPersonField && + mockCompanyRecord.accountOwner !== null && + mockCompanyRecord.accountOwner !== undefined + ) { + snapshot.set( + recordStoreFamilyState(mockCompanyRecord.accountOwner.id), + mockCompanyRecord.accountOwner, + ); + } + + // Set the related Person record for ONE_TO_MANY relation display + if (!isPersonField && !isOpportunityField && !isTimelineActivityField) { + snapshot.set( + recordStoreFamilyState(TEST_PERSON_RECORD_ID), + mockPersonRecord, + ); + } + + // Set the related WorkspaceMember record for TimelineActivity relation display + if (isTimelineActivityField) { + snapshot.set( + recordStoreFamilyState('test-workspace-member-xyz'), + mockWorkspaceMemberRecord, + ); + } + }; + + return ( + + + + + + + + + + + + ); + }, + ], + parameters: { + layout: 'centered', + }, + argTypes: { + widget: { + control: 'object', + description: 'Widget configuration', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const TextFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-text-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Company Name', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: nameField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const companyName = await canvas.findByText('Acme Corporation'); + expect(companyName).toBeVisible(); + }, +}; + +export const AddressFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-address-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Address', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 1, + column: 0, + rowSpan: 1, + columnSpan: 3, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: addressField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const street = await canvas.findByText(/123 Business St/i); + expect(street).toBeVisible(); + + const city = await canvas.findByText(/San Francisco/i); + expect(city).toBeVisible(); + }, +}; + +export const NumberFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-number-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Employees', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: employeesField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const employeeCount = await canvas.findByText('250'); + expect(employeeCount).toBeVisible(); + }, +}; + +export const LinkFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-link-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'LinkedIn', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 3, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: linkedinField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const linkedinLink = await canvas.findByText(/acme/i); + expect(linkedinLink).toBeVisible(); + }, +}; + +export const ManyToOneRelationFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-relation-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Account Owner', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 4, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: accountOwnerField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const accountOwner = await canvas.findByText('John Doe'); + expect(accountOwner).toBeVisible(); + }, +}; + +export const OneToManyRelationFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-one-to-many-relation-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'People', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 11, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: companyPeopleField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const personChip = await canvas.findByText('Jane Smith'); + expect(personChip).toBeVisible(); + }, +}; + +export const BooleanFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-boolean-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Ideal Customer Profile', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 5, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: idealCustomerProfileField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('True')).toBeVisible(); + }, +}; + +export const CurrencyFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-currency-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Annual Recurring Revenue', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 6, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: annualRecurringRevenueField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('5m')).toBeVisible(); + }, +}; + +export const EmailsFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-emails-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Emails', + objectMetadataId: personObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 7, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: personEmailsField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const email = await canvas.findByText(/jane\.smith@acme\.com/); + expect(email).toBeVisible(); + }, +}; + +export const PhonesFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-phones-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Phones', + objectMetadataId: personObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 8, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: personPhonesField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const phone = await canvas.findByText(/555/); + expect(phone).toBeVisible(); + }, +}; + +export const SelectFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-select-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Stage', + objectMetadataId: opportunityObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 9, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: opportunityStageField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Select field displays the selected option label + const stage = await canvas.findByText(/Proposal/); + expect(stage).toBeVisible(); + }, +}; + +export const MultiSelectFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-multi-select-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Work Policy', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 10, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: companyWorkPolicyField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Multi-select displays multiple chips + const onSiteChip = await canvas.findByText(/On-Site/); + expect(onSiteChip).toBeVisible(); + + const hybridChip = await canvas.findByText(/Hybrid/); + expect(hybridChip).toBeVisible(); + }, +}; + +export const TimelineActivityRelationFieldWidget: Story = { + args: { + widget: { + __typename: 'PageLayoutWidget', + id: 'widget-timeline-activity-relation-field', + pageLayoutTabId: 'tab-overview', + type: WidgetType.FIELD, + title: 'Workspace Member', + objectMetadataId: timelineActivityObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 12, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { + __typename: 'FieldConfiguration', + fieldMetadataId: timelineActivityWorkspaceMemberField.id, + layout: 'FIELD', + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + } as PageLayoutWidget, + }, + render: (args) => ( +
+ +
+ ), + 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(); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/hooks/useOpenFieldWidgetFieldInputEditMode.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/hooks/useOpenFieldWidgetFieldInputEditMode.ts new file mode 100644 index 0000000000..9599ee597b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/hooks/useOpenFieldWidgetFieldInputEditMode.ts @@ -0,0 +1,182 @@ +import { useOpenActivityTargetCellEditMode } from '@/activities/inline-cell/hooks/useOpenActivityTargetCellEditMode'; +import { type Note } from '@/activities/types/Note'; +import { type NoteTarget } from '@/activities/types/NoteTarget'; +import { type Task } from '@/activities/types/Task'; +import { type TaskTarget } from '@/activities/types/TaskTarget'; +import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput'; +import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput'; +import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput'; +import { useOpenRelationToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationToOneFieldInput'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; +import { + type FieldMetadata, + type FieldRelationFromManyValue, + type FieldRelationValue, +} from '@/object-record/record-field/ui/types/FieldMetadata'; +import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; +import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne'; +import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany'; +import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne'; +import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; + +export const useOpenFieldWidgetFieldInputEditMode = () => { + const { openRelationToOneFieldInput } = useOpenRelationToOneFieldInput(); + const { openRelationFromManyFieldInput } = + useOpenRelationFromManyFieldInput(); + + const { openMorphRelationOneToManyFieldInput } = + useOpenMorphRelationOneToManyFieldInput(); + + const { openActivityTargetCellEditMode } = + useOpenActivityTargetCellEditMode(); + + const { openMorphRelationManyToOneFieldInput } = + useOpenMorphRelationManyToOneFieldInput(); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + + const instanceId = useAvailableComponentInstanceIdOrThrow( + RecordFieldComponentInstanceContext, + ); + + const openFieldInput = useRecoilCallback( + ({ snapshot }) => + ({ + fieldDefinition, + recordId, + }: { + fieldDefinition: FieldDefinition; + recordId: string; + }) => { + if ( + isFieldRelationOneToMany(fieldDefinition) && + ['taskTarget', 'noteTarget'].includes( + fieldDefinition.metadata.relationObjectMetadataNameSingular, + ) + ) { + const fieldValue = snapshot + .getLoadable>( + recordStoreFamilySelector({ + recordId, + fieldName: fieldDefinition.metadata.fieldName, + }), + ) + .getValue(); + + const activity = snapshot + .getLoadable(recordStoreFamilyState(recordId)) + .getValue(); + + const objectMetadataItems = snapshot + .getLoadable(objectMetadataItemsState) + .getValue(); + + const activityTargetObjectRecords = getActivityTargetObjectRecords({ + activityRecord: activity as Task | Note, + objectMetadataItems, + activityTargets: fieldValue as NoteTarget[] | TaskTarget[], + }); + + openActivityTargetCellEditMode({ + recordPickerInstanceId: instanceId, + activityTargetObjectRecords, + }); + return; + } + + if (isFieldRelationManyToOne(fieldDefinition)) { + openRelationToOneFieldInput({ + fieldName: fieldDefinition.metadata.fieldName, + recordId, + prefix: instanceId, + }); + + return; + } + + if (isFieldMorphRelationOneToMany(fieldDefinition)) { + if (!isFieldMorphRelation(fieldDefinition)) { + throw new Error('Field is not a morph relation one to many'); + } + + openMorphRelationOneToManyFieldInput({ + recordId, + prefix: instanceId, + fieldDefinition, + }); + return; + } + + if (isFieldRelationOneToMany(fieldDefinition)) { + if ( + isDefined( + fieldDefinition.metadata.relationObjectMetadataNameSingular, + ) + ) { + openRelationFromManyFieldInput({ + fieldName: fieldDefinition.metadata.fieldName, + objectNameSingular: + fieldDefinition.metadata.relationObjectMetadataNameSingular, + recordId, + prefix: instanceId, + }); + return; + } + } + + if (isFieldMorphRelationManyToOne(fieldDefinition)) { + openMorphRelationManyToOneFieldInput({ + recordId, + prefix: instanceId, + fieldDefinition, + }); + return; + } + + pushFocusItemToFocusStack({ + focusId: instanceId, + component: { + type: FocusComponentType.OPENED_FIELD_INPUT, + instanceId: instanceId, + }, + globalHotkeysConfig: { + enableGlobalHotkeysConflictingWithKeyboard: false, + }, + }); + }, + [ + instanceId, + openActivityTargetCellEditMode, + openMorphRelationManyToOneFieldInput, + openMorphRelationOneToManyFieldInput, + openRelationFromManyFieldInput, + openRelationToOneFieldInput, + pushFocusItemToFocusStack, + ], + ); + + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const closeFieldInput = () => { + removeFocusItemFromFocusStackById({ + focusId: instanceId, + }); + }; + + return { + openFieldInput, + closeFieldInput, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/states/contexts/FieldWidgetComponentInstanceContext.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/states/contexts/FieldWidgetComponentInstanceContext.ts new file mode 100644 index 0000000000..782d0a5241 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/states/contexts/FieldWidgetComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const FieldWidgetComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/states/fieldWidgetHoverComponentState.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/states/fieldWidgetHoverComponentState.ts new file mode 100644 index 0000000000..722773d10d --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/states/fieldWidgetHoverComponentState.ts @@ -0,0 +1,8 @@ +import { FieldWidgetComponentInstanceContext } from '@/page-layout/widgets/field/states/contexts/FieldWidgetComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const fieldWidgetHoverComponentState = createComponentState({ + key: 'fieldWidgetHoverComponentState', + defaultValue: false, + componentInstanceContext: FieldWidgetComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/utils/assertFieldWidgetOrThrow.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/assertFieldWidgetOrThrow.ts new file mode 100644 index 0000000000..bdd06fa19e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/assertFieldWidgetOrThrow.ts @@ -0,0 +1,28 @@ +import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { isNonEmptyString } from '@sniptt/guards'; +import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; + +type AssertFieldWidgetOrThrow = ( + widget: PageLayoutWidget, +) => asserts widget is PageLayoutWidget & { + configuration: FieldConfiguration; +}; + +export const assertFieldWidgetOrThrow: AssertFieldWidgetOrThrow = ( + widget: PageLayoutWidget, +) => { + assertIsDefinedOrThrow( + widget.configuration, + new Error('Widget configuration is required'), + ); + + if ( + !isNonEmptyString(widget.configuration.__typename) || + widget.configuration.__typename !== 'FieldConfiguration' + ) { + throw new Error( + `Expected FieldConfiguration but got ${widget.configuration.__typename}`, + ); + } +}; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1765970658815-AddFieldWidgetType.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1765970658815-AddFieldWidgetType.ts new file mode 100644 index 0000000000..ed649dc81e --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1765970658815-AddFieldWidgetType.ts @@ -0,0 +1,45 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddFieldWidgetType1765970658815 implements MigrationInterface { + name = 'AddFieldWidgetType1765970658815'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "core"."pageLayoutWidget_type_enum" RENAME TO "pageLayoutWidget_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "core"."pageLayoutWidget_type_enum" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum" USING "type"::"text"::"core"."pageLayoutWidget_type_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`, + ); + await queryRunner.query( + `DROP TYPE "core"."pageLayoutWidget_type_enum_old"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "core"."pageLayoutWidget_type_enum_old" AS ENUM('VIEW', 'IFRAME', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN')`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum_old" USING "type"::"text"::"core"."pageLayoutWidget_type_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`, + ); + await queryRunner.query(`DROP TYPE "core"."pageLayoutWidget_type_enum"`); + await queryRunner.query( + `ALTER TYPE "core"."pageLayoutWidget_type_enum_old" RENAME TO "pageLayoutWidget_type_enum"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum.ts index 108dc645a9..156d008fdf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum.ts @@ -1,6 +1,7 @@ export enum WidgetType { VIEW = 'VIEW', IFRAME = 'IFRAME', + FIELD = 'FIELD', FIELDS = 'FIELDS', GRAPH = 'GRAPH', STANDALONE_RICH_TEXT = 'STANDALONE_RICH_TEXT',