Allow users to create Fields and Field widget (#18801)

- Allow users to create a Fields widget or a Field widget; **this PR is
focused on Fields widgets as Field widget can't be configured yet**
- Automatically create a filled view when the user creates a draft
Fields widget in edit mode


https://github.com/user-attachments/assets/b2dbba52-c614-44cd-bf6c-095ce9d4ec26

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Baptiste Devessier
2026-03-23 00:44:49 +01:00
committed by GitHub
parent dc00701448
commit e9aa6f47e5
19 changed files with 1921 additions and 771 deletions
@@ -7,7 +7,10 @@ import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageL
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
import { useReorderPageLayoutWidgets } from '@/page-layout/hooks/useReorderPageLayoutWidgets';
import { RecordPageAddWidgetSection } from '@/page-layout/widgets/components/RecordPageAddWidgetSection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
FeatureFlagKey,
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
@@ -28,6 +31,10 @@ export const PageLayoutContent = () => {
const isRecordPageLayout =
currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const isCanvasLayout = layoutMode === PageLayoutTabLayoutMode.CANVAS;
const isVerticalList = layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST;
@@ -36,12 +43,19 @@ export const PageLayoutContent = () => {
}
if (isVerticalList) {
if (!isRecordPageLayout && isPageLayoutInEditMode) {
if (
isPageLayoutInEditMode &&
isRecordPageLayout &&
isRecordPageGlobalEditionEnabled
) {
return (
<PageLayoutVerticalListEditor
widgets={activeTab.widgets}
onReorder={reorderWidgets}
isReorderEnabled={true}
trailingElement={
isRecordPageLayout ? <RecordPageAddWidgetSection /> : undefined
}
/>
);
}
@@ -6,16 +6,16 @@ import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer'
import { useIsInPinnedTab } from '@/page-layout/widgets/hooks/useIsInPinnedTab';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { styled } from '@linaria/react';
import {
DragDropContext,
Draggable,
Droppable,
type DropResult,
} from '@hello-pangea/dnd';
import { useId } from 'react';
import { useIsMobile } from 'twenty-ui/utilities';
import { styled } from '@linaria/react';
import { type ReactNode, useId } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledVerticalListContainer = styled.div<{
variant: PageLayoutVerticalListViewerVariant;
@@ -47,12 +47,14 @@ type PageLayoutVerticalListEditorProps = {
widgets: PageLayoutWidget[];
onReorder: (result: DropResult) => void;
isReorderEnabled?: boolean;
trailingElement?: ReactNode;
};
export const PageLayoutVerticalListEditor = ({
widgets,
onReorder,
isReorderEnabled = true,
trailingElement,
}: PageLayoutVerticalListEditorProps) => {
const droppableId = `page-layout-vertical-list-${useId()}`;
@@ -112,6 +114,7 @@ export const PageLayoutVerticalListEditor = ({
</Draggable>
))}
{provided.placeholder}
{trailingElement}
</StyledVerticalListContainer>
)}
</Droppable>
@@ -0,0 +1,86 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createDefaultFieldWidget } from '@/page-layout/utils/createDefaultFieldWidget';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { WidgetConfigurationType } from '~/generated-metadata/graphql';
export const useCreateRecordPageFieldWidget = () => {
const { tabId } = usePageLayoutContentContext();
const { targetObjectNameSingular } = useTargetRecord();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetObjectNameSingular,
});
const { boxedRelationFieldMetadataItems } = useFieldListFieldMetadataItems({
objectNameSingular: targetObjectNameSingular,
});
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const pageLayoutDraftState = useAtomComponentStateCallbackState(
pageLayoutDraftComponentState,
);
const store = useStore();
const createRecordPageFieldWidget = useCallback(() => {
const activeTab = currentPageLayout.tabs.find((tab) => tab.id === tabId);
const existingWidgets = activeTab?.widgets ?? [];
const usedFieldMetadataIds = new Set(
existingWidgets
.filter(
(widget) =>
widget.configuration.configurationType ===
WidgetConfigurationType.FIELD,
)
.map((widget) => {
const configuration = widget.configuration as {
fieldMetadataId: string;
};
return configuration.fieldMetadataId;
}),
);
const availableRelationField = boxedRelationFieldMetadataItems.find(
(field) => !usedFieldMetadataIds.has(field.id),
);
const fieldMetadataId = availableRelationField?.id ?? '';
const positionIndex = existingWidgets.length;
const widgetId = uuidv4();
const newWidget = createDefaultFieldWidget({
id: widgetId,
pageLayoutTabId: tabId,
fieldMetadataId,
objectMetadataId: objectMetadataItem.id,
positionIndex,
});
store.set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, tabId, newWidget),
}));
}, [
boxedRelationFieldMetadataItems,
currentPageLayout.tabs,
objectMetadataItem.id,
pageLayoutDraftState,
store,
tabId,
]);
return { createRecordPageFieldWidget };
};
@@ -0,0 +1,101 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { usePageLayoutContentContext } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab';
import { createDefaultFieldsWidget } from '@/page-layout/utils/createDefaultFieldsWidget';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
import { ViewType } from '~/generated-metadata/graphql';
export const useCreateRecordPageFieldsWidget = () => {
const { tabId } = usePageLayoutContentContext();
const { targetObjectNameSingular } = useTargetRecord();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetObjectNameSingular,
});
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const { performViewAPICreate } = usePerformViewAPIPersist();
const pageLayoutDraftState = useAtomComponentStateCallbackState(
pageLayoutDraftComponentState,
);
const pageLayoutEditingWidgetIdState = useAtomComponentStateCallbackState(
pageLayoutEditingWidgetIdComponentState,
);
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const store = useStore();
const createRecordPageFieldsWidget = useCallback(async () => {
const viewId = uuidv4();
const result = await performViewAPICreate(
{
input: {
id: viewId,
name: `${objectMetadataItem.labelSingular} Fields`,
icon: 'IconList',
objectMetadataId: objectMetadataItem.id,
type: ViewType.FIELDS_WIDGET,
},
},
objectMetadataItem.id,
);
if (result.status === 'failed') {
return;
}
const activeTab = currentPageLayout.tabs.find((tab) => tab.id === tabId);
const positionIndex = activeTab?.widgets.length ?? 0;
const widgetId = uuidv4();
const newWidget = createDefaultFieldsWidget({
id: widgetId,
pageLayoutTabId: tabId,
viewId,
objectMetadataId: objectMetadataItem.id,
positionIndex,
});
store.set(pageLayoutDraftState, (prev) => ({
...prev,
tabs: addWidgetToTab(prev.tabs, tabId, newWidget),
}));
store.set(pageLayoutEditingWidgetIdState, widgetId);
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutFieldsSettings,
focusTitleInput: true,
resetNavigationStack: true,
});
}, [
currentPageLayout.tabs,
navigatePageLayoutSidePanel,
objectMetadataItem.id,
objectMetadataItem.labelSingular,
pageLayoutDraftState,
pageLayoutEditingWidgetIdState,
performViewAPICreate,
store,
tabId,
]);
return { createRecordPageFieldsWidget };
};
@@ -0,0 +1,15 @@
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useCallback } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
export const useNavigateToMoreWidgets = () => {
const { navigatePageLayoutSidePanel } = useNavigatePageLayoutSidePanel();
const navigateToMoreWidgets = useCallback(() => {
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutWidgetTypeSelect,
});
}, [navigatePageLayoutSidePanel]);
return { navigateToMoreWidgets };
};
@@ -0,0 +1,51 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import {
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
export const createDefaultFieldWidget = ({
id,
pageLayoutTabId,
fieldMetadataId,
objectMetadataId,
positionIndex,
}: {
id: string;
pageLayoutTabId: string;
fieldMetadataId: string;
objectMetadataId: string;
positionIndex: number;
}): PageLayoutWidget => {
return {
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId,
title: '',
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId,
layout: 'CARD',
},
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: positionIndex,
},
objectMetadataId: objectMetadataId ?? null,
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
};
@@ -0,0 +1,50 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import {
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
export const createDefaultFieldsWidget = ({
id,
pageLayoutTabId,
viewId,
objectMetadataId,
positionIndex,
}: {
id: string;
pageLayoutTabId: string;
viewId: string;
objectMetadataId: string;
positionIndex: number;
}): PageLayoutWidget => {
return {
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId,
title: 'Fields',
type: WidgetType.FIELDS,
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId,
},
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetVerticalListPosition',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
index: positionIndex,
},
objectMetadataId: objectMetadataId ?? null,
isOverridden: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
};
};
@@ -0,0 +1,90 @@
import { useCreateRecordPageFieldWidget } from '@/page-layout/hooks/useCreateRecordPageFieldWidget';
import { useCreateRecordPageFieldsWidget } from '@/page-layout/hooks/useCreateRecordPageFieldsWidget';
import { useNavigateToMoreWidgets } from '@/page-layout/hooks/useNavigateToMoreWidgets';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import {
IconListDetails,
IconListSearch,
IconPlus,
IconSquarePlus,
} from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
border: 1px solid transparent;
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledHeader = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.primary};
display: flex;
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[6]};
padding-inline: ${themeCssVariables.spacing[1]};
`;
const StyledMenuItemList = styled.div`
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
margin-top: ${themeCssVariables.spacing[2]};
overflow: hidden;
padding: ${themeCssVariables.spacing[2]};
`;
export const RecordPageAddWidgetSection = () => {
const { theme } = useContext(ThemeContext);
const { createRecordPageFieldsWidget } = useCreateRecordPageFieldsWidget();
const { createRecordPageFieldWidget } = useCreateRecordPageFieldWidget();
const { navigateToMoreWidgets } = useNavigateToMoreWidgets();
return (
<StyledContainer>
<StyledHeader>
<IconSquarePlus
size={theme.icon.size.md}
stroke={theme.icon.stroke.md}
color={theme.font.color.extraLight}
/>
{t`Add widget`}
</StyledHeader>
<StyledMenuItemList>
<MenuItem
LeftIcon={IconListDetails}
withIconContainer
text={t`Fields group`}
contextualText={t`Group multiple fields from this record`}
onClick={createRecordPageFieldsWidget}
/>
<MenuItem
LeftIcon={IconListSearch}
withIconContainer
text={t`Field`}
contextualText={t`Single field with smart formats`}
onClick={createRecordPageFieldWidget}
/>
<MenuItem
LeftIcon={IconPlus}
withIconContainer
text={t`More widgets`}
hasSubMenu
onClick={navigateToMoreWidgets}
/>
</StyledMenuItemList>
</StyledContainer>
);
};
@@ -24,12 +24,14 @@ import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingC
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentFamilyState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { type MouseEvent, useContext } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { IconLock } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetType,
@@ -83,17 +85,27 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const isRecordPageLayout =
currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isLastWidget = useIsCurrentWidgetLastOfTab(widget.id);
const isReorderEnabled =
currentPageLayout.type !== PageLayoutType.RECORD_PAGE;
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isDeletingWidgetEnabled =
currentPageLayout.type !== PageLayoutType.RECORD_PAGE;
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isWidgetEditable =
isPageLayoutInEditMode &&
(currentPageLayout.type !== PageLayoutType.RECORD_PAGE ||
(!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled) ||
widget.type === WidgetType.FIELDS);
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
@@ -141,7 +153,9 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
// TODO: remove once all record page layouts widgets use the editable contain in edit mode
const shouldWrapWithEditingWrapper =
isWidgetEditable && variant === 'side-column';
isWidgetEditable &&
variant === 'side-column' &&
!isRecordPageGlobalEditionEnabled;
const widgetCard = (
<WidgetCard