Scaffold Fields widget edition (#17548)

https://github.com/user-attachments/assets/960b8b0d-10e8-42a1-bf61-e875359ea302

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Devessier <29370468+Devessier@users.noreply.github.com>
Co-authored-by: Devessier <baptiste@devessier.fr>
This commit is contained in:
Copilot
2026-02-03 15:56:41 +00:00
committed by GitHub
parent 66d7182d02
commit 43042d55b2
23 changed files with 681 additions and 139 deletions
@@ -0,0 +1,13 @@
import { CommandMenuPageLayoutInfoContent } from '@/command-menu/components/CommandMenuPageLayoutInfoContent';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuDashboardPageLayoutInfo = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
if (!isDefined(pageLayoutId)) {
return null;
}
return <CommandMenuPageLayoutInfoContent pageLayoutId={pageLayoutId} />;
};
@@ -55,6 +55,8 @@ export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
CommandMenuPages.PageLayoutGraphFilter,
CommandMenuPages.PageLayoutIframeSettings,
CommandMenuPages.PageLayoutTabSettings,
CommandMenuPages.PageLayoutFieldsSettings,
CommandMenuPages.PageLayoutFieldsLayout,
].includes(pageChip.page?.page)
: false;
@@ -1,144 +1,30 @@
import { usePageLayoutHeaderInfo } from '@/command-menu/components/hooks/usePageLayoutHeaderInfo';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { CommandMenuDashboardPageLayoutInfo } from '@/command-menu/components/CommandMenuDashboardPageLayoutInfo';
import { CommandMenuRecordPageLayoutInfo } from '@/command-menu/components/CommandMenuRecordPageLayoutInfo';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
export const CommandMenuPageLayoutInfo = () => {
const theme = useTheme();
const { getIcon } = useIcons();
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const [shouldFocusTitleInput, setShouldFocusTitleInput] =
useRecoilComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const handleTitleInputOpen = () => {
setShouldFocusTitleInput(false);
};
const draftPageLayout = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
const objectMetadataId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const pageLayoutEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const [openTabId] = useRecoilComponentState(
pageLayoutTabSettingsOpenTabIdComponentState,
pageLayoutId,
);
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { updatePageLayoutTab } = useUpdatePageLayoutTab(pageLayoutId);
const [editedTitle, setEditedTitle] = useState<string | null>(null);
const headerInfo = usePageLayoutHeaderInfo({
commandMenuPage,
draftPageLayout,
pageLayoutEditingWidgetId,
openTabId,
editedTitle,
});
if (!headerInfo) {
return null;
if (!isDefined(objectMetadataId)) {
throw new Error('Object metadata ID is not defined');
}
const {
headerIcon,
headerIconColor,
headerType,
title,
isReadonly,
tab,
widgetInEditMode,
} = headerInfo;
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
const Icon = headerIcon ?? getIcon('IconDefault');
const isDashboardContext =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Dashboard;
const handleTitleChange = (newTitle: string) => {
setEditedTitle(newTitle);
};
if (isDashboardContext) {
return <CommandMenuDashboardPageLayoutInfo />;
}
const saveTitle = async () => {
const finalTitle = editedTitle ?? title;
if (!isNonEmptyString(finalTitle)) {
return;
}
updateCommandMenuPageInfo({
pageTitle: finalTitle,
pageIcon: Icon,
});
if (
commandMenuPage === CommandMenuPages.PageLayoutTabSettings &&
isDefined(tab)
) {
updatePageLayoutTab(tab.id, { title: finalTitle });
} else if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: finalTitle,
});
}
setEditedTitle(null);
};
return (
<CommandMenuPageInfoLayout
icon={
isDefined(headerIcon) ? (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : undefined
}
iconColor={headerIconColor}
title={
<TitleInput
instanceId={`page-layout-title-${commandMenuPage}-${pageLayoutId}`}
disabled={isReadonly}
sizeVariant="sm"
value={title}
onChange={handleTitleChange}
placeholder={headerType}
onEnter={saveTitle}
onEscape={() => setEditedTitle(null)}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
shouldFocus={shouldFocusTitleInput}
onFocus={handleTitleInputOpen}
/>
}
label={headerType}
/>
);
return <CommandMenuRecordPageLayoutInfo />;
};
@@ -0,0 +1,146 @@
import { usePageLayoutHeaderInfo } from '@/command-menu/components/hooks/usePageLayoutHeaderInfo';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
export const CommandMenuPageLayoutInfoContent = ({
pageLayoutId,
}: {
pageLayoutId: string;
}) => {
const theme = useTheme();
const { getIcon } = useIcons();
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const [shouldFocusTitleInput, setShouldFocusTitleInput] =
useRecoilComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const handleTitleInputOpen = () => {
setShouldFocusTitleInput(false);
};
const draftPageLayout = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const [openTabId] = useRecoilComponentState(
pageLayoutTabSettingsOpenTabIdComponentState,
pageLayoutId,
);
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { updatePageLayoutTab } = useUpdatePageLayoutTab(pageLayoutId);
const [editedTitle, setEditedTitle] = useState<string | null>(null);
const headerInfo = usePageLayoutHeaderInfo({
commandMenuPage,
draftPageLayout,
pageLayoutEditingWidgetId,
openTabId,
editedTitle,
});
if (!headerInfo) {
return null;
}
const {
headerIcon,
headerIconColor,
headerType,
title,
isReadonly,
tab,
widgetInEditMode,
} = headerInfo;
const Icon = headerIcon ?? getIcon('IconDefault');
const handleTitleChange = (newTitle: string) => {
setEditedTitle(newTitle);
};
const saveTitle = async () => {
const finalTitle = editedTitle ?? title;
if (!isNonEmptyString(finalTitle)) {
return;
}
updateCommandMenuPageInfo({
pageTitle: finalTitle,
pageIcon: Icon,
});
if (
commandMenuPage === CommandMenuPages.PageLayoutTabSettings &&
isDefined(tab)
) {
updatePageLayoutTab(tab.id, { title: finalTitle });
} else if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: finalTitle,
});
}
setEditedTitle(null);
};
return (
<CommandMenuPageInfoLayout
icon={
isDefined(headerIcon) ? (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : undefined
}
iconColor={headerIconColor}
title={
<TitleInput
instanceId={`page-layout-title-${commandMenuPage}-${pageLayoutId}`}
disabled={isReadonly}
sizeVariant="sm"
value={title}
onChange={handleTitleChange}
placeholder={headerType}
onEnter={saveTitle}
onEscape={() => setEditedTitle(null)}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
shouldFocus={shouldFocusTitleInput}
onFocus={handleTitleInputOpen}
/>
}
label={headerType}
/>
);
};
@@ -0,0 +1,9 @@
import { CommandMenuPageLayoutInfoContent } from '@/command-menu/components/CommandMenuPageLayoutInfoContent';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
export const CommandMenuRecordPageLayoutInfo = () => {
const { pageLayoutId } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
return <CommandMenuPageLayoutInfoContent pageLayoutId={pageLayoutId} />;
};
@@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
import {
IconAppWindow,
IconFrame,
IconList,
IconPlus,
type IconComponent,
} from 'twenty-ui/display';
@@ -149,6 +150,37 @@ export const usePageLayoutHeaderInfo = ({
};
}
case CommandMenuPages.PageLayoutFieldsSettings:
case CommandMenuPages.PageLayoutFieldsLayout: {
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`Fields Widget`,
title,
isReadonly: false,
tab: undefined,
widgetInEditMode,
};
}
case CommandMenuPages.PageLayoutWidgetTypeSelect: {
return {
headerIcon: IconPlus,
@@ -5,6 +5,8 @@ import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-even
import { CommandMenuFrontComponentPage } from '@/command-menu/pages/front-component/components/CommandMenuFrontComponentPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuPageLayoutChartSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutChartSettings';
import { CommandMenuPageLayoutFieldsLayout } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsLayout';
import { CommandMenuPageLayoutFieldsSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsSettings';
import { CommandMenuPageLayoutGraphFilter } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphFilter';
import { CommandMenuPageLayoutIframeSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings';
import { CommandMenuPageLayoutTabSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings';
@@ -65,5 +67,13 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
CommandMenuPages.PageLayoutTabSettings,
<CommandMenuPageLayoutTabSettings />,
],
[
CommandMenuPages.PageLayoutFieldsSettings,
<CommandMenuPageLayoutFieldsSettings />,
],
[
CommandMenuPages.PageLayoutFieldsLayout,
<CommandMenuPageLayoutFieldsLayout />,
],
[CommandMenuPages.ViewFrontComponent, <CommandMenuFrontComponentPage />],
]);
@@ -42,7 +42,7 @@ export const CommandMenuPageLayoutChartSettings = () => {
value={{ instanceId: widgetInEditMode.id }}
>
<ChartSettings widget={widgetInEditMode} />
<WidgetSettingsFooter />
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</WidgetComponentInstanceContext.Provider>
</StyledContainer>
);
@@ -0,0 +1,65 @@
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/components/SidePanelSubPageNavigationHeader';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { useTemporaryFieldsConfiguration } from '@/page-layout/hooks/useTemporaryFieldsConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { FieldsConfigurationEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationEditor';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledOuterContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(2)};
overflow-y: auto;
`;
export const CommandMenuPageLayoutFieldsLayout = () => {
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const defaultFieldsConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
const [fieldsConfiguration, setFieldsConfiguration] =
useState<FieldsConfiguration>(defaultFieldsConfiguration);
if (!isDefined(widgetInEditMode)) {
return null;
}
const handleConfigurationChange = (
updatedConfiguration: FieldsConfiguration,
) => {
// TODO: replace with a call to updatePageLayoutWidget
setFieldsConfiguration(updatedConfiguration);
};
return (
<StyledOuterContainer>
<SidePanelSubPageNavigationHeader
title={t`Layout`}
onBackClick={goBackFromCommandMenu}
/>
<StyledContainer>
<FieldsConfigurationEditor
configuration={fieldsConfiguration}
onChange={handleConfigurationChange}
/>
</StyledContainer>
</StyledOuterContainer>
);
};
@@ -0,0 +1,94 @@
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { WidgetSettingsFooter } from '@/command-menu/pages/page-layout/components/WidgetSettingsFooter';
import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useTemporaryFieldsConfiguration } from '@/page-layout/hooks/useTemporaryFieldsConfiguration';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutSidebarRight } from 'twenty-ui/display';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledCommandMenuContainer = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
`;
export const CommandMenuPageLayoutFieldsSettings = () => {
const { t } = useLingui();
const { navigatePageLayoutCommandMenu } = useNavigatePageLayoutCommandMenu();
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldsConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
if (!isDefined(widgetInEditMode)) {
return null;
}
const totalFieldsCount = fieldsConfiguration.sections.reduce(
(count, section) => count + section.fields.length,
0,
);
const handleNavigateToLayout = () => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutFieldsLayout,
});
};
const selectableItemIds = [
'layout',
'display-more-fields-button',
'action-button',
'move-down',
'move-up',
'move-to-tab',
'add-widget-above',
'add-widget-below',
'delete',
];
return (
<StyledContainer>
<StyledCommandMenuContainer>
<CommandMenuList
commandGroups={[]}
selectableItemIds={selectableItemIds}
>
<CommandGroup heading={t`Customize`}>
<SelectableListItem
itemId="layout"
onEnter={handleNavigateToLayout}
>
<CommandMenuItem
id="layout"
label={t`Layout`}
Icon={IconLayoutSidebarRight}
hasSubMenu
onClick={handleNavigateToLayout}
description={t`${totalFieldsCount} fields`}
contextualTextPosition="right"
/>
</SelectableListItem>
</CommandGroup>
</CommandMenuList>
</StyledCommandMenuContainer>
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</StyledContainer>
);
};
@@ -93,7 +93,7 @@ export const CommandMenuPageLayoutIframeSettings = () => {
error={urlError}
/>
</StyledContainer>
<WidgetSettingsFooter />
<WidgetSettingsFooter pageLayoutId={pageLayoutId} />
</StyledOuterContainer>
);
};
@@ -1,4 +1,3 @@
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useDuplicatePageLayoutWidget } from '@/page-layout/hooks/useDuplicatePageLayoutWidget';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
@@ -14,12 +13,14 @@ import { isDefined } from 'twenty-shared/utils';
import { IconCopyPlus, IconTrash } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export const WidgetSettingsFooter = () => {
export const WidgetSettingsFooter = ({
pageLayoutId,
}: {
pageLayoutId: string;
}) => {
const dropdownId = useId();
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { duplicateWidget } = useDuplicatePageLayoutWidget(pageLayoutId);
const { deletePageLayoutWidget } = useDeletePageLayoutWidget(pageLayoutId);
const editingWidgetId = useRecoilComponentValue(
@@ -0,0 +1,46 @@
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
export const usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord =
() => {
const targetedRecordsRule = useRecoilComponentValue(
contextStoreTargetedRecordsRuleComponentState,
);
const objectMetadataId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
if (!isDefined(objectMetadataId)) {
throw new Error('Object metadata ID is not defined');
}
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId ?? undefined,
});
if (
!(
targetedRecordsRule.mode === 'selection' &&
targetedRecordsRule.selectedRecordIds.length === 1
)
) {
throw new Error('Only one record should be selected');
}
const recordId: string = targetedRecordsRule.selectedRecordIds[0];
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
return {
pageLayoutId,
objectNameSingular: objectMetadataItem.nameSingular,
};
};
@@ -5,4 +5,6 @@ export type PageLayoutCommandMenuPage =
| CommandMenuPages.PageLayoutGraphTypeSelect
| CommandMenuPages.PageLayoutIframeSettings
| CommandMenuPages.PageLayoutGraphFilter
| CommandMenuPages.PageLayoutTabSettings;
| CommandMenuPages.PageLayoutTabSettings
| CommandMenuPages.PageLayoutFieldsSettings
| CommandMenuPages.PageLayoutFieldsLayout;
@@ -6,6 +6,8 @@ import {
IconChartPie,
IconFilter,
IconFrame,
IconLayoutSidebarRight,
IconList,
} from 'twenty-ui/display';
export const getPageLayoutIcon = (page: PageLayoutCommandMenuPage) => {
@@ -20,6 +22,10 @@ export const getPageLayoutIcon = (page: PageLayoutCommandMenuPage) => {
return IconFilter;
case CommandMenuPages.PageLayoutTabSettings:
return IconAppWindow;
case CommandMenuPages.PageLayoutFieldsSettings:
return IconList;
case CommandMenuPages.PageLayoutFieldsLayout:
return IconLayoutSidebarRight;
default:
assertUnreachable(page);
}
@@ -15,6 +15,10 @@ export const getPageLayoutPageTitle = (page: PageLayoutCommandMenuPage) => {
return t`Configure filters`;
case CommandMenuPages.PageLayoutTabSettings:
return t`Tab Settings`;
case CommandMenuPages.PageLayoutFieldsSettings:
return t`Fields Settings`;
case CommandMenuPages.PageLayoutFieldsLayout:
return t`Layout`;
default:
assertUnreachable(page);
}
@@ -21,5 +21,7 @@ export enum CommandMenuPages {
PageLayoutGraphFilter = 'page-layout-graph-filter',
PageLayoutIframeSettings = 'page-layout-iframe-settings',
PageLayoutTabSettings = 'page-layout-tab-settings',
PageLayoutFieldsSettings = 'page-layout-fields-settings',
PageLayoutFieldsLayout = 'page-layout-fields-layout',
ViewFrontComponent = 'view-front-component',
}
@@ -56,6 +56,15 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
return;
}
if (widgetType === WidgetType.FIELDS) {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutFieldsSettings,
pageTitle: t`Edit Fields`,
resetNavigationStack: true,
});
return;
}
setCommandMenuPage(CommandMenuPages.Root);
closeCommandMenu();
},
@@ -4,6 +4,8 @@ export type FieldsConfigurationFieldItem = {
fieldMetadataId: string;
position: number;
conditionalDisplay?: RulesLogic;
// TODO: This property will be removed. Instead, we will only store the fields we want to display in FieldsConfiguration
isVisible?: boolean;
};
export type FieldsConfigurationSection = {
@@ -0,0 +1,56 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import {
type FieldsConfiguration,
type FieldsConfigurationSection,
} from '@/page-layout/types/FieldsConfiguration';
import { FieldsConfigurationSectionEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationSectionEditor';
type FieldsConfigurationEditorProps = {
configuration: FieldsConfiguration;
onChange: (configuration: FieldsConfiguration) => void;
};
export const FieldsConfigurationEditor = ({
configuration,
onChange,
}: FieldsConfigurationEditorProps) => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const handleSectionChange = (
sectionId: string,
updatedSection: FieldsConfigurationSection,
) => {
const updatedSections = configuration.sections.map((section) =>
section.id === sectionId ? updatedSection : section,
);
onChange({
...configuration,
sections: updatedSections,
});
};
const sortedSections = [...configuration.sections].sort(
(a, b) => a.position - b.position,
);
if (sortedSections.length === 0) {
return null;
}
return (
<>
{sortedSections.map((section, index) => (
<FieldsConfigurationSectionEditor
key={section.id}
section={section}
index={index}
objectMetadataItem={objectMetadataItem}
onSectionChange={(updatedSection) =>
handleSectionChange(section.id, updatedSection)
}
/>
))}
</>
);
};
@@ -0,0 +1,39 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsConfigurationFieldItem } from '@/page-layout/types/FieldsConfiguration';
import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/display';
import { MenuItemDraggable } from 'twenty-ui/navigation';
type FieldsConfigurationFieldEditorProps = {
field: FieldsConfigurationFieldItem;
fieldMetadata: FieldMetadataItem;
index: number;
onToggleVisibility: () => void;
};
export const FieldsConfigurationFieldEditor = ({
field,
fieldMetadata,
onToggleVisibility,
}: FieldsConfigurationFieldEditorProps) => {
const { getIcon } = useIcons();
const isVisible = field.isVisible !== false;
const FieldIcon = getIcon(fieldMetadata.icon);
return (
<MenuItemDraggable
LeftIcon={FieldIcon}
text={fieldMetadata.label}
showGrip
isIconDisplayedOnHoverOnly={false}
iconButtons={[
{
Icon: isVisible ? IconEye : IconEyeOff,
onClick: (e) => {
e.stopPropagation();
onToggleVisibility();
},
},
]}
/>
);
};
@@ -0,0 +1,112 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type FieldsConfigurationFieldItem,
type FieldsConfigurationSection,
} from '@/page-layout/types/FieldsConfiguration';
import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconDotsVertical } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledFieldsContainer = styled.div`
display: flex;
flex-direction: column;
`;
type FieldsConfigurationSectionEditorProps = {
section: FieldsConfigurationSection;
index: number;
objectMetadataItem: ObjectMetadataItem;
onSectionChange: (section: FieldsConfigurationSection) => void;
};
export const FieldsConfigurationSectionEditor = ({
section,
objectMetadataItem,
onSectionChange,
}: FieldsConfigurationSectionEditorProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const handleFieldChange = (
fieldMetadataId: string,
updatedField: FieldsConfigurationFieldItem,
) => {
const updatedFields = section.fields.map((field) =>
field.fieldMetadataId === fieldMetadataId ? updatedField : field,
);
onSectionChange({
...section,
fields: updatedFields,
});
};
const handleToggleFieldVisibility = (fieldMetadataId: string) => {
const field = section.fields.find(
(f) => f.fieldMetadataId === fieldMetadataId,
);
if (!field) {
return;
}
const currentlyVisible = field.isVisible !== false;
const updatedField: FieldsConfigurationFieldItem = {
...field,
isVisible: !currentlyVisible,
};
handleFieldChange(fieldMetadataId, updatedField);
};
const sortedFields = [...section.fields].sort(
(a, b) => a.position - b.position,
);
return (
<>
<MenuItem
text={section.title}
iconButtons={[
{
Icon: IconDotsVertical,
onClick: (e) => {
e.stopPropagation();
// TODO: Add section menu
},
},
]}
onClick={() => setIsExpanded(!isExpanded)}
hasSubMenu
isSubMenuOpened={isExpanded}
/>
{isExpanded && (
<StyledFieldsContainer>
{sortedFields.map((field, fieldIndex) => {
const fieldMetadata = objectMetadataItem.fields.find(
(f) => f.id === field.fieldMetadataId,
);
if (!fieldMetadata) {
return null;
}
return (
<FieldsConfigurationFieldEditor
key={field.fieldMetadataId}
field={field}
fieldMetadata={fieldMetadata}
index={fieldIndex}
onToggleVisibility={() =>
handleToggleFieldVisibility(field.fieldMetadataId)
}
/>
);
})}
</StyledFieldsContainer>
)}
</>
);
};
@@ -45,6 +45,12 @@ export const filterAndOrderFieldsFromConfiguration = ({
return false;
}
// Check user-controlled visibility flag
// TODO: This will be replaced by proper conditional display later
if (fieldConfig.isVisible === false) {
return false;
}
return evaluateWidgetVisibility({
conditionalDisplay: fieldConfig.conditionalDisplay,
context,