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:
committed by
GitHub
parent
79a03b8041
commit
b5ebc44fee
@@ -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',
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+7
-1
@@ -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) ||
|
||||
|
||||
+7
-1
@@ -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) ||
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+7
-1
@@ -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';
|
||||
};
|
||||
|
||||
+3
@@ -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,
|
||||
}}
|
||||
>
|
||||
<>
|
||||
|
||||
+140
-2
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+31
@@ -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;
|
||||
};
|
||||
|
||||
+4
@@ -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({
|
||||
|
||||
+161
@@ -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>
|
||||
);
|
||||
};
|
||||
+61
@@ -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>
|
||||
);
|
||||
};
|
||||
+49
@@ -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>
|
||||
);
|
||||
};
|
||||
+48
@@ -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>
|
||||
);
|
||||
};
|
||||
+122
@@ -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>
|
||||
);
|
||||
};
|
||||
+203
@@ -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>
|
||||
);
|
||||
};
|
||||
+59
@@ -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>
|
||||
);
|
||||
};
|
||||
+45
@@ -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>
|
||||
);
|
||||
};
|
||||
+110
@@ -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>
|
||||
);
|
||||
};
|
||||
+79
@@ -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>
|
||||
);
|
||||
};
|
||||
+89
@@ -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>
|
||||
);
|
||||
};
|
||||
+923
@@ -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();
|
||||
},
|
||||
};
|
||||
+182
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
|
||||
|
||||
export const FieldWidgetComponentInstanceContext =
|
||||
createComponentInstanceContext();
|
||||
+8
@@ -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,
|
||||
});
|
||||
+28
@@ -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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
+45
@@ -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
@@ -1,6 +1,7 @@
|
||||
export enum WidgetType {
|
||||
VIEW = 'VIEW',
|
||||
IFRAME = 'IFRAME',
|
||||
FIELD = 'FIELD',
|
||||
FIELDS = 'FIELDS',
|
||||
GRAPH = 'GRAPH',
|
||||
STANDALONE_RICH_TEXT = 'STANDALONE_RICH_TEXT',
|
||||
|
||||
Reference in New Issue
Block a user