Add field widget (#16609)

- Ability to display the details of a field
- The field can be edited (relations edition will be supported later)
- For now, the widget configuration stores the name of the field instead
of its fieldMetadataId. A hook resolves the fieldMetadataId from the
list of fields and the provided name. This will be replaced once we
migrate the configuration to the backend.

## Demo


https://github.com/user-attachments/assets/ab7efbda-66b2-46c1-b641-c350977c31dd

## Remaining to do

- Edition of relations
This commit is contained in:
Baptiste Devessier
2025-12-18 18:25:51 +01:00
committed by GitHub
parent 79a03b8041
commit b5ebc44fee
35 changed files with 2465 additions and 13 deletions
@@ -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',
@@ -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',
@@ -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';
};
@@ -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';
};
@@ -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) ||
@@ -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) ||
@@ -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';
};
@@ -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';
};
@@ -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';
};
@@ -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';
};
@@ -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,
}}
>
<>
@@ -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,
},
],
},
{
@@ -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]);
};
@@ -0,0 +1,5 @@
export type FieldConfiguration = {
__typename: 'FieldConfiguration';
fieldMetadataId: string;
layout: 'FIELD' | 'CARD' | 'VIEW';
};
@@ -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<string>;
conditionalDisplay?: RulesLogic;
configuration?: WidgetConfiguration | FieldsConfiguration | null;
configuration?:
| WidgetConfiguration
| FieldsConfiguration
| FieldConfiguration
| null;
};
@@ -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 <IframeWidget widget={widget} />;
case WidgetType.FIELD:
return <FieldWidget widget={widget} />;
case WidgetType.FIELDS:
return <FieldsWidget widget={widget} />;
@@ -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({
@@ -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 (
<FieldWidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<PropertyBoxSkeletonLoader />
</StyledContainer>
</RightDrawerProvider>
</FieldWidgetComponentInstanceContext.Provider>
);
}
if (!isDefined(fieldMetadataItem) || !fieldMetadataItem.isActive) {
return (
<FieldWidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
{t`No field configured`}
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
{t`Select a field to display in this widget`}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
</RightDrawerProvider>
</FieldWidgetComponentInstanceContext.Provider>
);
}
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: 0,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
});
if (isFieldMorphRelation(fieldDefinition)) {
return (
<FieldWidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<FieldWidgetMorphRelation
fieldMetadata={fieldDefinition.metadata}
recordId={targetRecord.id}
isInRightDrawer={isInRightDrawer}
/>
</FieldWidgetComponentInstanceContext.Provider>
);
}
if (isFieldRelation(fieldDefinition)) {
return (
<FieldWidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<FieldWidgetRelation
fieldMetadata={fieldDefinition.metadata}
relationValue={record}
isInRightDrawer={isInRightDrawer}
/>
</FieldWidgetComponentInstanceContext.Provider>
);
}
return (
<FieldWidgetComponentInstanceContext.Provider
value={{ instanceId: widget.id }}
>
<FieldWidgetDisplay
fieldDefinition={fieldDefinition}
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
isInRightDrawer={isInRightDrawer}
/>
</FieldWidgetComponentInstanceContext.Provider>
);
};
@@ -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 (
<RecordInlineCellAnchoredPortal
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={instanceId}
onCloseEditMode={handleCloseEditMode}
>
<FieldWidgetInputContextProvider>
<RecordInlineCellEditMode>
<FieldInput />
</RecordInlineCellEditMode>
</FieldWidgetInputContextProvider>
</RecordInlineCellAnchoredPortal>
);
};
@@ -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 (
<RecordInlineCellHoveredPortalContent
readonly={isRecordFieldReadOnly}
isCentered={isCentered}
onMouseLeave={onMouseLeave}
>
<RecordInlineCellDisplayMode isHovered={true} onClick={handleClick}>
{editModeContentOnly ? <FieldInput /> : <FieldDisplay />}
</RecordInlineCellDisplayMode>
</RecordInlineCellHoveredPortalContent>
);
};
@@ -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 (
<RecordInlineCellAnchoredPortal
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={instanceId}
>
<FieldWidgetCellHoveredContent onMouseLeave={onMouseLeave} />
</RecordInlineCellAnchoredPortal>
);
};
@@ -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<any>;
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 (
<RightDrawerProvider value={{ isInRightDrawer }}>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<StyledContainer>
<FieldContext.Provider
value={{
recordId,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: false,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly: fieldMetadataItem.isUIReadOnly ?? false,
},
}),
onMouseEnter: handleMouseEnter,
anchorId: getRecordFieldInputInstanceId({
recordId,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<FieldWidgetInlineCell instanceIdPrefix={instanceId} />
</FieldContext.Provider>
</StyledContainer>
<FieldWidgetCellHoveredPortal
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
recordId={recordId}
instanceId={instanceId}
isHovered={isHovered}
onMouseLeave={handleMouseLeave}
/>
<FieldWidgetCellEditModePortal
objectMetadataItem={objectMetadataItem}
fieldMetadataItem={fieldMetadataItem}
recordId={recordId}
instanceId={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</RightDrawerProvider>
);
};
@@ -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<FieldInputClickOutsideEvent>[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: <FieldInput />,
displayModeContent: <FieldDisplay />,
isDisplayModeFixHeight: isDisplayModeFixHeight,
editModeContentOnly: isFieldInputOnly,
loading: loading,
isCentered,
onOpenEditMode,
onCloseEditMode,
};
return (
<FieldInputEventContext.Provider
value={{
onCancel: handleCancel,
onEnter: handleEnter,
onEscape: handleEscape,
onClickOutside: handleClickOutside,
onShiftTab: handleShiftTab,
onSubmit: handleSubmit,
onTab: handleTab,
}}
>
<FieldFocusContextProvider>
<RecordInlineCellContext.Provider value={RecordInlineCellContextValue}>
<FieldWidgetInlineCellContainer />
</RecordInlineCellContext.Provider>
</FieldFocusContextProvider>
</FieldInputEventContext.Provider>
);
};
@@ -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 (
<StyledInlineCellBaseContainer
readonly={readonly ?? false}
onMouseEnter={handleContainerMouseEnter}
onMouseLeave={handleContainerMouseLeave}
>
<StyledValueContainer readonly={readonly ?? false} id={anchorId}>
<FieldWidgetInlineCellValue />
</StyledValueContainer>
</StyledInlineCellBaseContainer>
);
};
@@ -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 <RecordInlineCellSkeletonLoader />;
}
return (
<StyledClickableContainer readonly={readonly} isCentered={isCentered}>
<RecordInlineCellDisplayMode isHovered={false}>
<FieldDisplay />
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
);
};
@@ -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 (
<FieldInputEventContext.Provider
value={{
onCancel: handleCancel,
onEnter: handleEnter,
onEscape: handleEscape,
onClickOutside: handleClickOutside,
onShiftTab: handleShiftTab,
onSubmit: handleSubmit,
onTab: handleTab,
}}
>
{children}
</FieldInputEventContext.Provider>
);
};
@@ -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 (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<StyledRelationChipsContainer>
{recordsWithObjectNameSingular.map((morphItem, index) => (
<RecordChip
key={morphItem.value?.id ?? index}
objectNameSingular={morphItem.objectNameSingular}
record={morphItem.value}
/>
))}
</StyledRelationChipsContainer>
</StyledContainer>
</RightDrawerProvider>
);
}
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
{t`No related records`}
</AnimatedPlaceholderEmptyTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -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 (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<StyledRelationChipsContainer>
{relationValue.map((relatedRecord: any) => (
<RecordChip
key={relatedRecord.id}
objectNameSingular={relationObjectNameSingular}
record={relatedRecord}
/>
))}
</StyledRelationChipsContainer>
</StyledContainer>
</RightDrawerProvider>
);
}
if (!isOneToMany && isDefined(relationValue)) {
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<RecordChip
objectNameSingular={relationObjectNameSingular}
record={relationValue}
/>
</StyledContainer>
</RightDrawerProvider>
);
}
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
{t`No related records`}
</AnimatedPlaceholderEmptyTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
</RightDrawerProvider>
);
};
@@ -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<typeof FieldWidget> = {
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 (
<MemoryRouter>
<JestMetadataAndApolloMocksWrapper>
<PageLayoutTestWrapper initializeState={initializeState}>
<LayoutRenderingProvider
value={{
isInRightDrawer: false,
layoutType: PageLayoutType.RECORD_PAGE,
targetRecordIdentifier: {
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
},
}}
>
<PageLayoutContentProvider
value={{
layoutMode: 'vertical-list',
tabId: 'fields',
}}
>
<Story />
</PageLayoutContentProvider>
</LayoutRenderingProvider>
</PageLayoutTestWrapper>
</JestMetadataAndApolloMocksWrapper>
</MemoryRouter>
);
},
],
parameters: {
layout: 'centered',
},
argTypes: {
widget: {
control: 'object',
description: 'Widget configuration',
},
},
};
export default meta;
type Story = StoryObj<typeof FieldWidget>;
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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) => (
<div style={{ width: '400px', padding: '20px' }}>
<FieldWidget widget={args.widget} />
</div>
),
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();
},
};
@@ -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<FieldMetadata>;
recordId: string;
}) => {
if (
isFieldRelationOneToMany(fieldDefinition) &&
['taskTarget', 'noteTarget'].includes(
fieldDefinition.metadata.relationObjectMetadataNameSingular,
)
) {
const fieldValue = snapshot
.getLoadable<FieldRelationValue<FieldRelationFromManyValue>>(
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,
};
};
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const FieldWidgetComponentInstanceContext =
createComponentInstanceContext();
@@ -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<boolean>({
key: 'fieldWidgetHoverComponentState',
defaultValue: false,
componentInstanceContext: FieldWidgetComponentInstanceContext,
});
@@ -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}`,
);
}
};
@@ -0,0 +1,45 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddFieldWidgetType1765970658815 implements MigrationInterface {
name = 'AddFieldWidgetType1765970658815';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}
@@ -1,6 +1,7 @@
export enum WidgetType {
VIEW = 'VIEW',
IFRAME = 'IFRAME',
FIELD = 'FIELD',
FIELDS = 'FIELDS',
GRAPH = 'GRAPH',
STANDALONE_RICH_TEXT = 'STANDALONE_RICH_TEXT',