Field widget edition (#19209)

https://github.com/user-attachments/assets/2f1a5847-3375-414f-a2b8-ce4b533b5512

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Baptiste Devessier
2026-04-01 17:44:49 +02:00
committed by GitHub
parent 136f362b24
commit 20ac5b7e84
14 changed files with 413 additions and 10 deletions
@@ -52,11 +52,15 @@ export const useCreateRecordPageFieldWidget = () => {
}),
);
const availableRelationField = boxedRelationFieldMetadataItems.find(
const unusedRelationField = boxedRelationFieldMetadataItems.find(
(field) => !usedFieldMetadataIds.has(field.id),
);
const fieldMetadataId = availableRelationField?.id ?? '';
const selectedField =
unusedRelationField ?? boxedRelationFieldMetadataItems[0];
const fieldMetadataId = selectedField?.id ?? '';
const title = selectedField?.label ?? '';
const positionIndex = existingWidgets.length;
const widgetId = uuidv4();
@@ -64,6 +68,7 @@ export const useCreateRecordPageFieldWidget = () => {
const newWidget = createDefaultFieldWidget({
id: widgetId,
pageLayoutTabId: tabId,
title,
fieldMetadataId,
objectMetadataId: objectMetadataItem.id,
positionIndex,
@@ -66,6 +66,16 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
return;
}
if (widgetType === WidgetType.FIELD) {
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutFieldSettings,
pageTitle: t`Field widget`,
resetNavigationStack: true,
});
setPageLayoutEditingWidgetId(widgetId);
return;
}
if (widgetType === WidgetType.RECORD_TABLE) {
navigatePageLayoutSidePanel({
sidePanelPage: SidePanelPages.PageLayoutRecordTableSettings,
@@ -9,12 +9,14 @@ import {
export const createDefaultFieldWidget = ({
id,
pageLayoutTabId,
title,
fieldMetadataId,
objectMetadataId,
positionIndex,
}: {
id: string;
pageLayoutTabId: string;
title: string;
fieldMetadataId: string;
objectMetadataId: string;
positionIndex: number;
@@ -23,7 +25,7 @@ export const createDefaultFieldWidget = ({
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId,
title: '',
title,
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
@@ -106,7 +106,8 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
isPageLayoutInEditMode &&
(!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled) ||
widget.type === WidgetType.FIELDS);
widget.type === WidgetType.FIELDS ||
widget.type === WidgetType.FIELD);
// TODO: when we have more widgets without headers, we should use a more generic approach to hide the header
// each widget type could have metadata (e.g., hasHeader: boolean or headerMode: 'always' | 'editOnly' | 'never')
@@ -5,9 +5,8 @@ import {
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { selectedNavigationMenuItemIdInEditModeState } from '@/navigation-menu-item/common/states/selectedNavigationMenuItemIdInEditModeState';
import { useNavigationMenuItemSectionItems } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { SidePanelAskAIInfo } from '@/side-panel/components/SidePanelAskAIInfo';
import { SidePanelFolderInfo } from '@/side-panel/components/SidePanelFolderInfo';
import { SidePanelLinkInfo } from '@/side-panel/components/SidePanelLinkInfo';
@@ -18,6 +17,7 @@ import { SidePanelPageLayoutInfo } from '@/side-panel/components/SidePanelPageLa
import { SidePanelRecordInfo } from '@/side-panel/components/SidePanelRecordInfo';
import { SidePanelWorkflowStepInfo } from '@/side-panel/components/SidePanelWorkflowStepInfo';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import { type SidePanelContextChipProps } from '@/side-panel/components/SidePanelContextChip';
import { useContext } from 'react';
@@ -101,6 +101,7 @@ export const SidePanelPageInfo = ({ pageChip }: SidePanelPageInfoProps) => {
SidePanelPages.PageLayoutIframeSettings,
SidePanelPages.PageLayoutTabSettings,
SidePanelPages.PageLayoutFieldsSettings,
SidePanelPages.PageLayoutFieldSettings,
SidePanelPages.PageLayoutRecordTableSettings,
].includes(pageChip.page?.page)
: false;
@@ -175,6 +175,36 @@ export const usePageLayoutHeaderInfo = ({
};
}
case SidePanelPages.PageLayoutFieldSettings: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
}
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const title = isDefined(editedTitle)
? editedTitle
: isDefined(widgetInEditMode.title) && widgetInEditMode.title !== ''
? widgetInEditMode.title
: '';
return {
headerIcon: IconList,
headerIconColor: iconColor,
headerType: t`Field Widget`,
title,
isReadonly: false,
tab: undefined,
widgetInEditMode,
};
}
case SidePanelPages.PageLayoutRecordTableSettings: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
@@ -1,16 +1,17 @@
import { SidePanelCommandMenuItemEditPage } from '@/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage';
import { SidePanelCommandMenuItemDisplayPage } from '@/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage';
import { SidePanelCommandMenuItemEditPage } from '@/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage';
import { SidePanelNavigationMenuItemEditPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNavigationMenuItemEditPage';
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAIChatThreadsPage';
import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage';
import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage';
import { SidePanelFrontComponentPage } from '@/side-panel/pages/front-component/components/SidePanelFrontComponentPage';
import { SidePanelMessageThreadPage } from '@/side-panel/pages/message-thread/components/SidePanelMessageThreadPage';
import { SidePanelNavigationMenuItemEditPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNavigationMenuItemEditPage';
import { SidePanelNewSidebarItemPage } from '@/navigation-menu-item/edit/side-panel/components/SidePanelNewSidebarItemPage';
import { SidePanelPageLayoutChartSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutChartSettings';
import { SidePanelPageLayoutFieldSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldSettings';
import { SidePanelPageLayoutFieldsSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutFieldsSettings';
import { SidePanelPageLayoutRecordTableSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings';
import { SidePanelPageLayoutIframeSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutIframeSettings';
import { SidePanelPageLayoutRecordTableSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutRecordTableSettings';
import { SidePanelPageLayoutTabSettings } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutTabSettings';
import { SidePanelPageLayoutWidgetTypeSelect } from '@/side-panel/pages/page-layout/components/SidePanelPageLayoutWidgetTypeSelect';
import { SidePanelMergeRecordPage } from '@/side-panel/pages/record-page/components/SidePanelMergeRecordPage';
@@ -82,6 +83,10 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map<SidePanelPages, React.ReactNode>(
SidePanelPages.PageLayoutFieldsSettings,
<SidePanelPageLayoutFieldsSettings />,
],
[
SidePanelPages.PageLayoutFieldSettings,
<SidePanelPageLayoutFieldSettings />,
],
[
SidePanelPages.PageLayoutRecordTableSettings,
<SidePanelPageLayoutRecordTableSettings />,
@@ -0,0 +1,110 @@
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { FieldWidgetFieldDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent';
import { FieldWidgetLayoutDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetLayoutDropdownContent';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutSidebarRight, IconListDetails } from 'twenty-ui/display';
import {
FieldDisplayMode,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledSidePanelContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
`;
export const SidePanelPageLayoutFieldSettings = () => {
const { t } = useLingui();
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldConfiguration = widgetInEditMode?.configuration as
| FieldConfiguration
| undefined;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const { fieldMetadataItem: currentFieldMetadataItem } =
useFieldMetadataItemById(currentFieldMetadataId ?? '');
if (!isDefined(widgetInEditMode)) {
return null;
}
const fieldLabel = currentFieldMetadataItem?.label ?? '';
const displayModeLabels: Record<string, string> = {
[FieldDisplayMode.FIELD]: t`Field`,
[FieldDisplayMode.CARD]: t`Card`,
};
const layoutLabel = isDefined(currentDisplayMode)
? (displayModeLabels[currentDisplayMode] ?? '')
: '';
const selectableItemIds = ['field', 'layout'];
return (
<StyledContainer>
<StyledSidePanelContainer>
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Data and display`}>
<SelectableListItem itemId="field">
<CommandMenuItemDropdown
id="field"
label={t`Field`}
Icon={IconListDetails}
dropdownId="field"
dropdownComponents={
<DropdownContent>
<FieldWidgetFieldDropdownContent />
</DropdownContent>
}
dropdownPlacement="bottom-end"
description={fieldLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem itemId="layout">
<CommandMenuItemDropdown
id="layout"
label={t`Layout`}
Icon={IconLayoutSidebarRight}
dropdownId="layout"
dropdownComponents={
<DropdownContent>
<FieldWidgetLayoutDropdownContent />
</DropdownContent>
}
dropdownPlacement="bottom-end"
description={layoutLabel}
contextualTextPosition="right"
/>
</SelectableListItem>
</SidePanelGroup>
</SidePanelList>
</StyledSidePanelContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</StyledContainer>
);
};
@@ -0,0 +1,132 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useIcons } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type FieldConfiguration } from '~/generated-metadata/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const FieldWidgetFieldDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldConfiguration = widgetInEditMode?.configuration as
| FieldConfiguration
| undefined;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const { boxedRelationFieldMetadataItems } = useFieldListFieldMetadataItems({
objectNameSingular,
});
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
const availableFields = filterBySearchQuery({
items: boxedRelationFieldMetadataItems,
searchQuery,
getSearchableValues: (item) => [item.label],
});
const { fieldMetadataItem: currentFieldMetadataItem } =
useFieldMetadataItemById(currentFieldMetadataId ?? '');
const handleSelectField = (fieldMetadataId: string) => {
updateCurrentWidgetConfig({
configToUpdate: {
fieldMetadataId,
},
});
const selectedField = boxedRelationFieldMetadataItems.find(
(field) => field.id === fieldMetadataId,
);
if (widgetInEditMode && selectedField) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedField.label,
});
}
closeDropdown();
};
return (
<>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem.id);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={currentFieldMetadataId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(
currentFieldMetadataId === fieldMetadataItem.id
? currentFieldMetadataItem?.icon
: fieldMetadataItem.icon,
)}
onClick={() => {
handleSelectField(fieldMetadataItem.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
};
@@ -0,0 +1,101 @@
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useLingui } from '@lingui/react/macro';
import { IconLayoutKanban, IconListDetails } from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
FieldDisplayMode,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
const LAYOUT_OPTIONS = [
{
id: FieldDisplayMode.FIELD,
Icon: IconListDetails,
},
{
id: FieldDisplayMode.CARD,
Icon: IconLayoutKanban,
},
] as const;
export const FieldWidgetLayoutDropdownContent = () => {
const { t } = useLingui();
const { pageLayoutId } = usePageLayoutIdFromContextStore();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldConfiguration = widgetInEditMode?.configuration as
| FieldConfiguration
| undefined;
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { closeDropdown } = useCloseDropdown();
const handleSelectLayout = (fieldDisplayMode: FieldDisplayMode) => {
updateCurrentWidgetConfig({
configToUpdate: {
fieldDisplayMode,
},
});
closeDropdown();
};
const layoutLabels: Record<(typeof LAYOUT_OPTIONS)[number]['id'], string> = {
[FieldDisplayMode.FIELD]: t`Field`,
[FieldDisplayMode.CARD]: t`Card`,
};
return (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={LAYOUT_OPTIONS.map((option) => option.id)}
>
{LAYOUT_OPTIONS.map((option) => (
<SelectableListItem
key={option.id}
itemId={option.id}
onEnter={() => {
handleSelectLayout(option.id);
}}
>
<MenuItemSelect
text={layoutLabels[option.id]}
selected={currentDisplayMode === option.id}
focused={selectedItemId === option.id}
LeftIcon={option.Icon}
onClick={() => {
handleSelectLayout(option.id);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -6,4 +6,5 @@ export type PageLayoutSidePanelPage =
| SidePanelPages.PageLayoutIframeSettings
| SidePanelPages.PageLayoutTabSettings
| SidePanelPages.PageLayoutFieldsSettings
| SidePanelPages.PageLayoutFieldSettings
| SidePanelPages.PageLayoutRecordTableSettings;
@@ -21,6 +21,8 @@ export const getPageLayoutIcon = (page: PageLayoutSidePanelPage) => {
return IconAppWindow;
case SidePanelPages.PageLayoutFieldsSettings:
return IconList;
case SidePanelPages.PageLayoutFieldSettings:
return IconList;
case SidePanelPages.PageLayoutRecordTableSettings:
return IconTable;
default:
@@ -15,6 +15,8 @@ export const getPageLayoutPageTitle = (page: PageLayoutSidePanelPage) => {
return t`Tab Settings`;
case SidePanelPages.PageLayoutFieldsSettings:
return t`Fields Settings`;
case SidePanelPages.PageLayoutFieldSettings:
return t`Field widget`;
case SidePanelPages.PageLayoutRecordTableSettings:
return t`Record Table Settings`;
default:
@@ -22,6 +22,7 @@ export enum SidePanelPages {
PageLayoutTabSettings = 'page-layout-tab-settings',
PageLayoutFieldsSettings = 'page-layout-fields-settings',
PageLayoutRecordTableSettings = 'page-layout-record-table-settings',
PageLayoutFieldSettings = 'page-layout-field-settings',
ViewFrontComponent = 'view-front-component',
NavigationMenuItemEdit = 'navigation-menu-item-edit',
NavigationMenuAddItem = 'navigation-menu-add-item',