chore(page-layout): remove IS_RECORD_PAGE_LAYOUT_* feature flags (#20556)

## Summary

- Both \`IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED\` and
\`IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED\` are force-enabled on
every existing workspace by the 1.23.0 upgrade command
\`BackfillRecordPageLayoutsCommand\` and seeded enabled for new
workspaces via \`DEFAULT_FEATURE_FLAGS\` +
\`seed-feature-flags.util.ts\`. They are no longer load-bearing.
- Unwrap all \`if (flag) { … }\` conditionals to their enabled branch on
both server and front.
- Delete legacy fallback files that only the disabled branch reached:
\`PageLayoutRelationWidgetsSyncEffect\`,
\`usePageLayoutWithRelationWidgets\`,
\`reInjectDynamicRelationWidgetsFromDraft\`,
\`injectRelationWidgetsIntoLayout\`, \`isDynamicRelationWidget\` (and
their tests).
- Strip the two \`enableFeatureFlags\` calls from the 1.23 upgrade
command — the page-layout backfill data logic itself is kept intact
since old workspaces upgrading from < 1.23 still need it.
- No DB cleanup migration: stale \`featureFlag\` rows are left in place,
matching the precedent set by #20531 and #20460.

Net diff: 37 files, +106 / -1727.

## Test plan

- [x] \`npx nx typecheck twenty-shared twenty-server twenty-front\` —
all pass
- [x] \`npx nx lint:diff-with-main twenty-server twenty-front\` — all
pass
- [x] \`cd packages/twenty-front && npx jest page-layout\` — 1240 tests,
all pass
- [x] \`cd packages/twenty-server && npx jest
workspace-entity-manager.spec\` — pass
- [ ] Manual smoke: open a record page, verify tabs render and \"Edit
Layout\" command-menu action is available
- [ ] Manual smoke: Settings → Data model → object → Layout tab is
visible (and hidden for remote / Dashboard objects)
- [ ] Manual smoke: edit a tab title, save, reload — confirm persistence
This commit is contained in:
Charles Bochet
2026-05-14 08:12:00 +02:00
committed by GitHub
parent 6dd1e8a471
commit 04eb913551
37 changed files with 107 additions and 1705 deletions
@@ -1750,12 +1750,10 @@ enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED
IS_JSON_FILTER_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
}
@@ -1391,7 +1391,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -8762,12 +8762,10 @@ export const enumFeatureFlagKey = {
IS_UNIQUE_INDEXES_ENABLED: 'IS_UNIQUE_INDEXES_ENABLED' as const,
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAILING_DOMAIN_ENABLED: 'IS_EMAILING_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const
}
@@ -276,8 +276,6 @@ export enum FeatureFlagKey {
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
}
@@ -1647,8 +1647,6 @@ export enum FeatureFlagKey {
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
}
@@ -18,15 +18,12 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutDraftToUpdateInput } from '@/page-layout/utils/convertPageLayoutDraftToUpdateInput';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { isDefaultPageLayoutId } from '@/page-layout/utils/isDefaultPageLayoutId';
import { reInjectDynamicRelationWidgetsFromDraft } from '@/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useLingui } from '@lingui/react/macro';
import { useStore } from 'jotai';
import { useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { logError } from '~/utils/logError';
@@ -48,10 +45,6 @@ export const useSaveLayoutCustomization = () => {
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const save = useCallback(async () => {
setIsSaving(true);
try {
@@ -119,10 +112,7 @@ export const useSaveLayoutCustomization = () => {
await createPendingRecordTableWidgetViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft, {
shouldFilterDynamicRelationWidgets:
!isRecordPageLayoutEditingEnabled,
});
const updateInput = convertPageLayoutDraftToUpdateInput(draft);
const result = await updatePageLayoutWithTabsAndWidgets(
pageLayoutId,
updateInput,
@@ -136,26 +126,17 @@ export const useSaveLayoutCustomization = () => {
const persistedLayout: PageLayout =
transformPageLayout(updatedPageLayout);
const pageLayoutToPersist =
!isRecordPageLayoutEditingEnabled &&
persistedLayout.type === PageLayoutType.RECORD_PAGE
? reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
)
: persistedLayout;
store.set(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
pageLayoutToPersist,
persistedLayout,
);
store.set(
pageLayoutCurrentLayoutsComponentState.atomFamily({
instanceId: pageLayoutId,
}),
convertPageLayoutToTabLayouts(pageLayoutToPersist),
convertPageLayoutToTabLayouts(persistedLayout),
);
}
} else {
@@ -194,7 +175,6 @@ export const useSaveLayoutCustomization = () => {
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,
enqueueErrorSnackBar,
isRecordPageLayoutEditingEnabled,
store,
t,
]);
@@ -9,10 +9,8 @@ import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/u
import { useReorderPageLayoutWidgets } from '@/page-layout/hooks/useReorderPageLayoutWidgets';
import { StandaloneWidgetPlaceholder } from '@/page-layout/widgets/components/StandaloneWidgetPlaceholder';
import { RecordPageAddWidgetSection } from '@/page-layout/widgets/components/RecordPageAddWidgetSection';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import {
FeatureFlagKey,
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
@@ -38,10 +36,6 @@ 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;
@@ -62,19 +56,13 @@ export const PageLayoutContent = () => {
}
if (isVerticalList) {
if (
isPageLayoutInEditMode &&
isRecordPageLayout &&
isRecordPageGlobalEditionEnabled
) {
if (isPageLayoutInEditMode && isRecordPageLayout) {
return (
<PageLayoutVerticalListEditor
widgets={activeTab.widgets}
onReorder={reorderWidgets}
isReorderEnabled={true}
trailingElement={
isRecordPageLayout ? <RecordPageAddWidgetSection /> : undefined
}
trailingElement={<RecordPageAddWidgetSection />}
/>
);
}
@@ -1,5 +1,4 @@
import { useBasePageLayout } from '@/page-layout/hooks/useBasePageLayout';
import { usePageLayoutWithRelationWidgets } from '@/page-layout/hooks/usePageLayoutWithRelationWidgets';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
@@ -8,27 +7,24 @@ import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayo
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutInitializationQueryEffectProps = {
pageLayoutId: string;
};
const PageLayoutInitializationEffect = ({
// oxlint-disable-next-line twenty/effect-components
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
pageLayout,
}: {
pageLayoutId: string;
pageLayout: PageLayout | undefined;
}) => {
}: PageLayoutInitializationQueryEffectProps) => {
const pageLayout = useBasePageLayout(pageLayoutId);
const [pageLayoutIsInitialized, setPageLayoutIsInitialized] =
useAtomComponentState(pageLayoutIsInitializedComponentState);
@@ -99,53 +95,3 @@ const PageLayoutInitializationEffect = ({
return null;
};
const PageLayoutInitializationWithRelationWidgets = ({
pageLayoutId,
basePageLayout,
}: {
pageLayoutId: string;
basePageLayout: PageLayout | undefined;
}) => {
const pageLayout = usePageLayoutWithRelationWidgets(basePageLayout);
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={pageLayout}
/>
);
};
// oxlint-disable-next-line twenty/effect-components
export const PageLayoutInitializationQueryEffect = ({
pageLayoutId,
}: PageLayoutInitializationQueryEffectProps) => {
const { layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const basePageLayout = useBasePageLayout(pageLayoutId);
const needsRelationWidgets =
layoutType === PageLayoutType.RECORD_PAGE &&
!isRecordPageLayoutEditingEnabled;
if (needsRelationWidgets) {
return (
<PageLayoutInitializationWithRelationWidgets
pageLayoutId={pageLayoutId}
basePageLayout={basePageLayout}
/>
);
}
return (
<PageLayoutInitializationEffect
pageLayoutId={pageLayoutId}
pageLayout={basePageLayout}
/>
);
};
@@ -1,193 +0,0 @@
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { useBasePageLayout } from '@/page-layout/hooks/useBasePageLayout';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/pageLayoutIsInitializedComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { injectRelationWidgetsIntoLayout } from '@/page-layout/utils/injectRelationWidgetsIntoLayout';
import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWidget';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType, WidgetType } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
type PageLayoutRelationWidgetsSyncEffectProps = {
pageLayoutId: string;
};
export const PageLayoutRelationWidgetsSyncEffect = ({
pageLayoutId,
}: PageLayoutRelationWidgetsSyncEffectProps) => {
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const pageLayoutIsInitialized = useAtomComponentStateValue(
pageLayoutIsInitializedComponentState,
);
const basePageLayout = useBasePageLayout(pageLayoutId);
const { boxedRelationFieldMetadataItems } = useFieldListFieldMetadataItems({
objectNameSingular: targetRecordIdentifier?.targetObjectNameSingular ?? '',
});
const pageLayoutPersistedComponentCallbackState =
useAtomComponentStateCallbackState(pageLayoutPersistedComponentState);
const pageLayoutDraftComponentCallbackState =
useAtomComponentStateCallbackState(pageLayoutDraftComponentState);
const pageLayoutCurrentLayoutsComponentCallbackState =
useAtomComponentStateCallbackState(pageLayoutCurrentLayoutsComponentState);
const store = useStore();
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const getDraftWithSyncedDynamicRelationWidgets = useCallback(
(
currentDraft: DraftPageLayout,
layoutWithRelationWidgets: PageLayout,
): DraftPageLayout => {
return {
...currentDraft,
tabs: currentDraft.tabs.map((draftTab) => {
const persistedTab = layoutWithRelationWidgets.tabs.find(
(tab) => tab.id === draftTab.id,
);
if (!isDefined(persistedTab)) {
return draftTab;
}
const dynamicRelationWidgets = persistedTab.widgets.filter(
isDynamicRelationWidget,
);
const nonDynamicWidgets = draftTab.widgets.filter(
(widget) => !isDynamicRelationWidget(widget),
);
if (dynamicRelationWidgets.length === 0) {
return {
...draftTab,
widgets: nonDynamicWidgets,
};
}
const firstFieldsWidgetIndex = nonDynamicWidgets.findIndex(
(widget) => widget.type === WidgetType.FIELDS,
);
if (firstFieldsWidgetIndex === -1) {
return {
...draftTab,
widgets: [...nonDynamicWidgets, ...dynamicRelationWidgets],
};
}
const widgetsBeforeFields = nonDynamicWidgets.slice(
0,
firstFieldsWidgetIndex + 1,
);
const widgetsAfterFields = nonDynamicWidgets.slice(
firstFieldsWidgetIndex + 1,
);
return {
...draftTab,
widgets: [
...widgetsBeforeFields,
...dynamicRelationWidgets,
...widgetsAfterFields,
],
};
}),
};
},
[],
);
const syncPageLayoutWithRelationWidgets = useCallback(
(layout: PageLayout) => {
const currentPersisted = store.get(
pageLayoutPersistedComponentCallbackState,
);
if (!isDeeplyEqual(layout, currentPersisted)) {
store.set(pageLayoutPersistedComponentCallbackState, layout);
const currentDraft = store.get(pageLayoutDraftComponentCallbackState);
const nextDraft = isLayoutCustomizationModeEnabled
? getDraftWithSyncedDynamicRelationWidgets(currentDraft, layout)
: {
id: layout.id,
name: layout.name,
type: layout.type,
objectMetadataId: layout.objectMetadataId,
tabs: layout.tabs,
defaultTabToFocusOnMobileAndSidePanelId:
layout.defaultTabToFocusOnMobileAndSidePanelId,
};
if (!isDeeplyEqual(nextDraft, currentDraft)) {
store.set(pageLayoutDraftComponentCallbackState, nextDraft);
}
const tabLayouts = convertPageLayoutToTabLayouts({
...layout,
tabs: nextDraft.tabs,
});
store.set(pageLayoutCurrentLayoutsComponentCallbackState, tabLayouts);
}
},
[
getDraftWithSyncedDynamicRelationWidgets,
isLayoutCustomizationModeEnabled,
pageLayoutCurrentLayoutsComponentCallbackState,
pageLayoutDraftComponentCallbackState,
pageLayoutPersistedComponentCallbackState,
store,
],
);
useEffect(() => {
if (!pageLayoutIsInitialized) {
return;
}
if (!isDefined(basePageLayout)) {
return;
}
const isRecordPage = layoutType === PageLayoutType.RECORD_PAGE;
if (!isRecordPage) {
return;
}
const layoutWithRelationWidgets = injectRelationWidgetsIntoLayout(
basePageLayout,
boxedRelationFieldMetadataItems,
);
syncPageLayoutWithRelationWidgets(layoutWithRelationWidgets);
}, [
basePageLayout,
boxedRelationFieldMetadataItems,
pageLayoutIsInitialized,
layoutType,
syncPageLayoutWithRelationWidgets,
]);
return null;
};
@@ -1,16 +1,13 @@
import { PageLayoutEditModeProvider } from '@/page-layout/components/PageLayoutEditModeProvider';
import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/PageLayoutInitializationQueryEffect';
import { PageLayoutRecordPageCustomizationSessionRegistrationEffect } from '@/page-layout/components/PageLayoutRecordPageCustomizationSessionRegistrationEffect';
import { PageLayoutRelationWidgetsSyncEffect } from '@/page-layout/components/PageLayoutRelationWidgetsSyncEffect';
import { PageLayoutRendererContent } from '@/page-layout/components/PageLayoutRendererContent';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
type PageLayoutRendererProps = {
pageLayoutId: string;
@@ -21,10 +18,6 @@ export const PageLayoutRenderer = ({
}: PageLayoutRendererProps) => {
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId,
layoutType,
@@ -48,12 +41,6 @@ export const PageLayoutRenderer = ({
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutRecordPageCustomizationSessionRegistrationEffect />
{!isRecordPageLayoutEditingEnabled &&
layoutType === PageLayoutType.RECORD_PAGE && (
<PageLayoutRelationWidgetsSyncEffect
pageLayoutId={pageLayoutId}
/>
)}
<PageLayoutRendererContent />
</PageLayoutEditModeProvider>
</TabListComponentInstanceContext.Provider>
@@ -3,7 +3,6 @@ import { PageLayoutContent } from '@/page-layout/components/PageLayoutContent';
import { PageLayoutEditModeProvider } from '@/page-layout/components/PageLayoutEditModeProvider';
import { PageLayoutInitializationQueryEffect } from '@/page-layout/components/PageLayoutInitializationQueryEffect';
import { PageLayoutRecordPageCustomizationSessionRegistrationEffect } from '@/page-layout/components/PageLayoutRecordPageCustomizationSessionRegistrationEffect';
import { PageLayoutRelationWidgetsSyncEffect } from '@/page-layout/components/PageLayoutRelationWidgetsSyncEffect';
import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext';
import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow';
import { usePageLayoutTabWithVisibleWidgetsOrThrow } from '@/page-layout/hooks/usePageLayoutTabWithVisibleWidgetsOrThrow';
@@ -16,8 +15,6 @@ import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingC
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
type PageLayoutSingleTabRendererProps = {
pageLayoutId: string;
@@ -79,10 +76,6 @@ export const PageLayoutSingleTabRenderer = ({
}: PageLayoutSingleTabRendererProps) => {
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const tabListInstanceId = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId,
layoutType,
@@ -106,9 +99,6 @@ export const PageLayoutSingleTabRenderer = ({
>
<PageLayoutInitializationQueryEffect pageLayoutId={pageLayoutId} />
<PageLayoutRecordPageCustomizationSessionRegistrationEffect />
{!isRecordPageLayoutEditingEnabled && (
<PageLayoutRelationWidgetsSyncEffect pageLayoutId={pageLayoutId} />
)}
<PageLayoutSingleTabRendererContent />
</PageLayoutEditModeProvider>
</TabListComponentInstanceContext.Provider>
@@ -46,14 +46,10 @@ import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab-list/components/Ta
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
type PageLayoutType,
} from '~/generated-metadata/graphql';
import { type PageLayoutType } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
box-sizing: border-box;
@@ -112,10 +108,6 @@ export const PageLayoutTabList = ({
const { getIcon } = useIcons();
const { t } = useLingui();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const tabsWithIcons: SingleTabProps[] = tabs.map((tab) => ({
id: tab.id,
title: tab.title,
@@ -266,10 +258,7 @@ export const PageLayoutTabList = ({
(tabId: string) => {
const shouldOpenSettings =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(
pageLayoutType,
isRecordPageGlobalEditionEnabled,
);
shouldEnableTabEditingFeatures(pageLayoutType);
if (shouldOpenSettings && activeTabId === tabId) {
openTabSettings(tabId);
@@ -284,7 +273,6 @@ export const PageLayoutTabList = ({
},
[
isPageLayoutInEditMode,
isRecordPageGlobalEditionEnabled,
pageLayoutType,
activeTabId,
isTabSettingsOpen,
@@ -297,10 +285,7 @@ export const PageLayoutTabList = ({
(tabId: string) => {
const shouldOpenSettings =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(
pageLayoutType,
isRecordPageGlobalEditionEnabled,
);
shouldEnableTabEditingFeatures(pageLayoutType);
if (shouldOpenSettings && activeTabId === tabId) {
openTabSettings(tabId);
@@ -316,7 +301,6 @@ export const PageLayoutTabList = ({
},
[
isPageLayoutInEditMode,
isRecordPageGlobalEditionEnabled,
pageLayoutType,
activeTabId,
isTabSettingsOpen,
@@ -26,14 +26,10 @@ import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { ThemeContext } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
type PageLayoutType,
} from '~/generated-metadata/graphql';
import { type PageLayoutType } from '~/generated-metadata/graphql';
const StyledOverflowDropdownListDraggableWrapper = styled.div`
cursor: grab;
@@ -79,16 +75,8 @@ export const PageLayoutTabListReorderableOverflowDropdown = ({
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const shouldShowEditButton =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(
pageLayoutType,
isRecordPageGlobalEditionEnabled,
);
isPageLayoutInEditMode && shouldEnableTabEditingFeatures(pageLayoutType);
const isPageLayoutTabDragging = useAtomComponentStateValue(
isPageLayoutTabDraggingComponentState,
@@ -21,11 +21,9 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
const StyledContainer = styled.div<{ hasPinnedTab: boolean }>`
display: grid;
grid-template-columns: ${({ hasPinnedTab }) =>
@@ -73,10 +71,6 @@ export const PageLayoutTabsRenderer = () => {
const isMobile = useIsMobile();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const metadataStore = useAtomFamilyStateValue(
metadataStoreState,
'objectMetadataItems',
@@ -90,10 +84,7 @@ export const PageLayoutTabsRenderer = () => {
const canEnableTabEditing =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(
currentPageLayout.type,
isRecordPageGlobalEditionEnabled,
);
shouldEnableTabEditingFeatures(currentPageLayout.type);
const tabsWithVisibleWidgets = getTabsWithVisibleWidgets({
tabs: currentPageLayout.tabs,
@@ -1,333 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { usePageLayoutWithRelationWidgets } from '@/page-layout/hooks/usePageLayoutWithRelationWidgets';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { renderHook } from '@testing-library/react';
import {
AggregateOperations,
BarChartLayout,
GraphOrderBy,
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
jest.mock('@/ui/layout/contexts/LayoutRenderingContext');
jest.mock(
'@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems',
);
describe('usePageLayoutWithRelationWidgets', () => {
const mockBasePageLayout: PageLayout = {
__typename: 'PageLayout',
id: 'test-layout',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'obj-1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'tab-1',
title: 'Fields',
isActive: true,
icon: 'IconList',
position: 100,
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
pageLayoutId: 'test-layout',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'widget-fields',
applicationId: '',
pageLayoutTabId: 'tab-1',
title: 'Fields',
isActive: true,
type: WidgetType.FIELDS,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
{
__typename: 'PageLayoutWidget',
id: 'widget-notes',
applicationId: '',
pageLayoutTabId: 'tab-1',
title: 'Notes',
isActive: true,
type: WidgetType.NOTES,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 1,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'NotesConfiguration',
configurationType: WidgetConfigurationType.NOTES,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
{
__typename: 'PageLayoutWidget',
id: 'widget-other',
applicationId: '',
pageLayoutTabId: 'tab-1',
title: 'Other',
isActive: true,
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 2,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'BarChartConfiguration',
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
aggregateOperation: AggregateOperations.COUNT,
aggregateFieldMetadataId: 'id',
primaryAxisGroupByFieldMetadataId: 'createdAt',
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: false,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
};
const mockRelationFields: FieldMetadataItem[] = [
{
id: 'field-1',
universalIdentifier: 'field-1',
label: 'Related Companies',
name: 'relatedCompanies',
type: 'RELATION',
isNullable: true,
isActive: true,
isSystem: false,
isCustom: false,
defaultValue: null,
options: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fromRelationMetadata: null,
toRelationMetadata: null,
relationDefinition: null,
settings: null,
} as FieldMetadataItem,
{
id: 'field-2',
universalIdentifier: 'field-2',
label: 'Related People',
name: 'relatedPeople',
type: 'RELATION',
isNullable: true,
isActive: true,
isSystem: false,
isCustom: false,
defaultValue: null,
options: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
fromRelationMetadata: null,
toRelationMetadata: null,
relationDefinition: null,
settings: null,
} as FieldMetadataItem,
];
beforeEach(() => {
(useLayoutRenderingContext as jest.Mock).mockReturnValue({
targetRecordIdentifier: {
targetObjectNameSingular: 'company',
},
layoutType: PageLayoutType.RECORD_PAGE,
});
(useFieldListFieldMetadataItems as jest.Mock).mockReturnValue({
boxedRelationFieldMetadataItems: mockRelationFields,
});
});
it('should inject relation widgets after the first FIELDS widget', () => {
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(mockBasePageLayout),
);
const firstTab = result.current?.tabs[0];
expect(firstTab).toBeDefined();
const widgets = firstTab?.widgets || [];
expect(widgets.length).toBe(5); // 1 FIELDS + 2 relation + 1 NOTES + 1 OTHER
// First widget should be FIELDS
expect(widgets[0].type).toBe(WidgetType.FIELDS);
expect(widgets[0].id).toBe('widget-fields');
// Next two should be relation widgets
expect(widgets[1].type).toBe(WidgetType.FIELD);
expect(widgets[1].title).toBe('Related Companies');
expect(widgets[2].type).toBe(WidgetType.FIELD);
expect(widgets[2].title).toBe('Related People');
// Then NOTES widget
expect(widgets[3].type).toBe(WidgetType.NOTES);
expect(widgets[3].id).toBe('widget-notes');
// Finally OTHER widget
expect(widgets[4].type).toBe(WidgetType.GRAPH);
expect(widgets[4].id).toBe('widget-other');
});
it('should handle layout with no FIELDS widget by appending to end', () => {
const layoutWithoutFields: PageLayout = {
...mockBasePageLayout,
tabs: [
{
...mockBasePageLayout.tabs[0],
widgets: [
{
__typename: 'PageLayoutWidget',
id: 'widget-other',
applicationId: '',
pageLayoutTabId: 'tab-1',
title: 'Other',
isActive: true,
type: WidgetType.GRAPH,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'BarChartConfiguration',
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
aggregateOperation: AggregateOperations.COUNT,
aggregateFieldMetadataId: 'id',
primaryAxisGroupByFieldMetadataId: 'createdAt',
primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
displayDataLabel: false,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
},
],
},
],
};
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(layoutWithoutFields),
);
const firstTab = result.current?.tabs[0];
const widgets = firstTab?.widgets || [];
expect(widgets.length).toBe(3); // 1 OTHER + 2 relation
expect(widgets[0].type).toBe(WidgetType.GRAPH);
expect(widgets[1].type).toBe(WidgetType.FIELD);
expect(widgets[2].type).toBe(WidgetType.FIELD);
});
it('should return unchanged layout when no relation fields exist', () => {
(useFieldListFieldMetadataItems as jest.Mock).mockReturnValue({
boxedRelationFieldMetadataItems: [],
});
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(mockBasePageLayout),
);
expect(result.current).toEqual(mockBasePageLayout);
});
it('should return undefined when basePageLayout is undefined', () => {
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(undefined),
);
expect(result.current).toBeUndefined();
});
it('should return unchanged layout when not a record page', () => {
(useLayoutRenderingContext as jest.Mock).mockReturnValue({
targetRecordIdentifier: {
targetObjectNameSingular: 'company',
},
layoutType: PageLayoutType.DASHBOARD,
});
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(mockBasePageLayout),
);
expect(result.current).toEqual(mockBasePageLayout);
});
it('should handle layout without Note widget', () => {
const layoutWithoutNotes: PageLayout = {
...mockBasePageLayout,
tabs: [
{
...mockBasePageLayout.tabs[0],
widgets: [
mockBasePageLayout.tabs[0].widgets[0], // FIELDS widget
mockBasePageLayout.tabs[0].widgets[2], // OTHER widget
],
},
],
};
const { result } = renderHook(() =>
usePageLayoutWithRelationWidgets(layoutWithoutNotes),
);
const firstTab = result.current?.tabs[0];
const widgets = firstTab?.widgets || [];
expect(widgets.length).toBe(4); // 1 FIELDS + 2 relation + 1 OTHER
expect(widgets[0].type).toBe(WidgetType.FIELDS);
expect(widgets[1].type).toBe(WidgetType.FIELD);
expect(widgets[2].type).toBe(WidgetType.FIELD);
expect(widgets[3].type).toBe(WidgetType.GRAPH);
});
});
@@ -8,12 +8,11 @@ import { shouldEnableTabEditingFeatures } from '@/page-layout/utils/shouldEnable
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { SidePanelPages } from 'twenty-shared/types';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
import { PageLayoutType } from '~/generated-metadata/graphql';
export const usePageLayoutAddTabStrategy = ({
pageLayoutId,
@@ -25,10 +24,6 @@ export const usePageLayoutAddTabStrategy = ({
const { currentPageLayout } = useCurrentPageLayoutOrThrow();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const { createPageLayoutTab } = useCreatePageLayoutTab({
pageLayoutId,
tabListInstanceId,
@@ -66,10 +61,7 @@ export const usePageLayoutAddTabStrategy = ({
const isEnabled =
isPageLayoutInEditMode &&
shouldEnableTabEditingFeatures(
currentPageLayout.type,
isRecordPageGlobalEditionEnabled,
);
shouldEnableTabEditingFeatures(currentPageLayout.type);
if (!isEnabled) {
return undefined;
@@ -1,31 +0,0 @@
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { injectRelationWidgetsIntoLayout } from '@/page-layout/utils/injectRelationWidgetsIntoLayout';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
export const usePageLayoutWithRelationWidgets = (
basePageLayout: PageLayout | undefined,
): PageLayout | undefined => {
const { targetRecordIdentifier, layoutType } = useLayoutRenderingContext();
const { boxedRelationFieldMetadataItems } = useFieldListFieldMetadataItems({
objectNameSingular: targetRecordIdentifier?.targetObjectNameSingular ?? '',
});
if (!isDefined(basePageLayout)) {
return undefined;
}
const isRecordPage = layoutType === PageLayoutType.RECORD_PAGE;
if (!isRecordPage) {
return basePageLayout;
}
return injectRelationWidgetsIntoLayout(
basePageLayout,
boxedRelationFieldMetadataItems,
);
};
@@ -8,15 +8,12 @@ import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayo
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { convertPageLayoutDraftToUpdateInput } from '@/page-layout/utils/convertPageLayoutDraftToUpdateInput';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { reInjectDynamicRelationWidgetsFromDraft } from '@/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft';
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey, PageLayoutType } from '~/generated-metadata/graphql';
export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
@@ -49,9 +46,6 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
const store = useStore();
const savePageLayout = useCallback(async () => {
@@ -59,9 +53,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
await createPendingRecordTableWidgetViews(pageLayoutId);
const pageLayoutDraft = store.get(pageLayoutDraftCallbackState);
const updateInput = convertPageLayoutDraftToUpdateInput(pageLayoutDraft, {
shouldFilterDynamicRelationWidgets: !isRecordPageLayoutEditingEnabled,
});
const updateInput = convertPageLayoutDraftToUpdateInput(pageLayoutDraft);
const result = await updatePageLayoutWithTabsAndWidgets(
pageLayoutId,
@@ -76,19 +68,10 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const persistedLayout: PageLayout =
transformPageLayout(updatedPageLayout);
const pageLayoutToPersist =
!isRecordPageLayoutEditingEnabled &&
persistedLayout.type === PageLayoutType.RECORD_PAGE
? reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
pageLayoutDraft,
)
: persistedLayout;
store.set(pageLayoutPersistedCallbackState, pageLayoutToPersist);
store.set(pageLayoutPersistedCallbackState, persistedLayout);
store.set(
pageLayoutCurrentLayoutsCallbackState,
convertPageLayoutToTabLayouts(pageLayoutToPersist),
convertPageLayoutToTabLayouts(persistedLayout),
);
}
}
@@ -97,7 +80,6 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
}, [
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
isRecordPageLayoutEditingEnabled,
pageLayoutCurrentLayoutsCallbackState,
pageLayoutDraftCallbackState,
pageLayoutId,
@@ -1,7 +1,6 @@
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { convertPageLayoutDraftToUpdateInput } from '@/page-layout/utils/convertPageLayoutDraftToUpdateInput';
import { DYNAMIC_RELATION_WIDGET_ID_PREFIX } from '@/page-layout/utils/isDynamicRelationWidget';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
@@ -350,81 +349,4 @@ describe('convertPageLayoutDraftToUpdateInput', () => {
columnSpan: 6,
});
});
describe('shouldFilterDynamicRelationWidgets', () => {
it('should filter out dynamic relation widgets when shouldFilterDynamicRelationWidgets is true', () => {
const regularWidget = makeWidget({ id: 'w1' });
const dynamicWidget = makeWidget({
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}relation-1`,
type: WidgetType.VIEW,
});
const draft = makeDraft([
makeTab('tab-1', [regularWidget, dynamicWidget]),
]);
const result = convertPageLayoutDraftToUpdateInput(draft, {
shouldFilterDynamicRelationWidgets: true,
});
expect(result.tabs[0].widgets).toHaveLength(1);
expect(result.tabs[0].widgets[0].id).toBe('w1');
});
it('should keep all non-dynamic widgets when shouldFilterDynamicRelationWidgets is true and multiple widget types exist', () => {
const fieldsWidget = makeWidget({ id: 'w1', type: WidgetType.FIELDS });
const timelineWidget = makeWidget({
id: 'w2',
type: WidgetType.TIMELINE,
});
const dynamicWidget = makeWidget({
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}rel`,
});
const draft = makeDraft([
makeTab('tab-1', [fieldsWidget, dynamicWidget, timelineWidget]),
]);
const result = convertPageLayoutDraftToUpdateInput(draft, {
shouldFilterDynamicRelationWidgets: true,
});
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[0].widgets.map((w) => w.id)).toEqual(['w1', 'w2']);
});
it('should not filter dynamic relation widgets when shouldFilterDynamicRelationWidgets is false', () => {
const regularWidget = makeWidget({ id: 'w1' });
const dynamicWidget = makeWidget({
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}relation-1`,
type: WidgetType.VIEW,
});
const draft = makeDraft([
makeTab('tab-1', [regularWidget, dynamicWidget]),
]);
const result = convertPageLayoutDraftToUpdateInput(draft, {
shouldFilterDynamicRelationWidgets: false,
});
expect(result.tabs[0].widgets).toHaveLength(2);
});
it('should not filter dynamic relation widgets by default', () => {
const regularWidget = makeWidget({ id: 'w1' });
const dynamicWidget = makeWidget({
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}relation-1`,
type: WidgetType.VIEW,
});
const draft = makeDraft([
makeTab('tab-1', [regularWidget, dynamicWidget]),
]);
const result = convertPageLayoutDraftToUpdateInput(draft);
expect(result.tabs[0].widgets).toHaveLength(2);
});
});
});
@@ -1,201 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { injectRelationWidgetsIntoLayout } from '@/page-layout/utils/injectRelationWidgetsIntoLayout';
import {
FieldDisplayMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
const createMockWidget = (
id: string,
type: WidgetType = WidgetType.FIELDS,
): PageLayoutWidget =>
({
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId: 'tab-1',
title: `Widget ${id}`,
type,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetGridPosition',
layoutMode: 'GRID',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
}) as PageLayoutWidget;
const createMockTab = (
id: string,
widgets: PageLayoutWidget[],
): PageLayoutTab =>
({
__typename: 'PageLayoutTab',
applicationId: '',
id,
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
position: 0,
widgets,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
}) as PageLayoutTab;
const createMockLayout = (tabs: PageLayoutTab[]): PageLayout =>
({
__typename: 'PageLayout',
id: 'layout-1',
tabs,
}) as PageLayout;
const createMockFieldMetadataItem = (
id: string,
label: string,
): FieldMetadataItem =>
({
id,
label,
name: label.toLowerCase(),
type: 'RELATION',
}) as FieldMetadataItem;
describe('injectRelationWidgetsIntoLayout', () => {
it('should return layout unchanged when relation fields array is empty', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('w1')]),
]);
const result = injectRelationWidgetsIntoLayout(layout, []);
expect(result).toBe(layout);
});
it('should return layout unchanged when there are no tabs', () => {
const layout = createMockLayout([]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result).toBe(layout);
});
it('should append relation widgets when no FIELDS widget exists', () => {
const otherWidget = createMockWidget('w1', WidgetType.TIMELINE);
const layout = createMockLayout([createMockTab('tab-1', [otherWidget])]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[0].widgets[0].id).toBe('w1');
expect(result.tabs[0].widgets[1].id).toContain('dynamic-relation-widget-');
expect(result.tabs[0].widgets[1].id).toContain('f1');
});
it('should inject relation widgets after the first FIELDS widget', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
const layout = createMockLayout([
createMockTab('tab-1', [fieldsWidget, timelineWidget]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toContain('f1');
expect(result.tabs[0].widgets[2].id).toBe('timeline-1');
});
it('should reposition NOTES widget after relation widgets', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const notesWidget = createMockWidget('notes-1', WidgetType.NOTES);
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
const layout = createMockLayout([
createMockTab('tab-1', [fieldsWidget, notesWidget, timelineWidget]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
const widgetIds = result.tabs[0].widgets.map((w) => w.id);
expect(widgetIds[0]).toBe('fields-1');
expect(widgetIds[1]).toContain('f1');
expect(widgetIds[2]).toBe('notes-1');
expect(widgetIds[3]).toBe('timeline-1');
});
it('should only modify the first tab', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
createMockTab('tab-2', [createMockWidget('fields-2', WidgetType.FIELDS)]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[1].widgets).toHaveLength(1);
expect(result.tabs[1].widgets[0].id).toBe('fields-2');
});
it('should inject multiple relation widgets in order', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const layout = createMockLayout([createMockTab('tab-1', [fieldsWidget])]);
const fields = [
createMockFieldMetadataItem('f1', 'Company'),
createMockFieldMetadataItem('f2', 'Person'),
];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toContain('f1');
expect(result.tabs[0].widgets[2].id).toContain('f2');
});
it('should set correct properties on injected relation widgets', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
const injectedWidget = result.tabs[0].widgets[1];
expect(injectedWidget.type).toBe(WidgetType.FIELD);
expect(injectedWidget.title).toBe('Company');
expect(injectedWidget.pageLayoutTabId).toBe('tab-1');
expect(injectedWidget.configuration).toEqual(
expect.objectContaining({
fieldMetadataId: 'f1',
fieldDisplayMode: FieldDisplayMode.CARD,
}),
);
});
});
@@ -1,43 +0,0 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import {
DYNAMIC_RELATION_WIDGET_ID_PREFIX,
isDynamicRelationWidget,
} from '@/page-layout/utils/isDynamicRelationWidget';
const makeWidget = (id: string): PageLayoutWidget =>
({
id,
title: 'Widget',
type: 'FIELDS',
pageLayoutTabId: 'tab-1',
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
configuration: {},
}) as PageLayoutWidget;
describe('isDynamicRelationWidget', () => {
it('should return true for a widget with the dynamic relation prefix', () => {
const widget = makeWidget(
`${DYNAMIC_RELATION_WIDGET_ID_PREFIX}some-relation`,
);
expect(isDynamicRelationWidget(widget)).toBe(true);
});
it('should return false for a widget without the dynamic relation prefix', () => {
const widget = makeWidget('regular-widget-id');
expect(isDynamicRelationWidget(widget)).toBe(false);
});
it('should return false for a widget with a similar but different prefix', () => {
const widget = makeWidget('dynamic-relation-other-widget-123');
expect(isDynamicRelationWidget(widget)).toBe(false);
});
it('should return true for the prefix alone as id', () => {
const widget = makeWidget(DYNAMIC_RELATION_WIDGET_ID_PREFIX);
expect(isDynamicRelationWidget(widget)).toBe(true);
});
});
@@ -1,194 +0,0 @@
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { DYNAMIC_RELATION_WIDGET_ID_PREFIX } from '@/page-layout/utils/isDynamicRelationWidget';
import { reInjectDynamicRelationWidgetsFromDraft } from '@/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft';
import { WidgetType } from '~/generated-metadata/graphql';
const makeWidget = (
overrides: Partial<PageLayoutWidget> & { id: string },
): PageLayoutWidget =>
({
title: 'Widget',
type: WidgetType.FIELDS,
pageLayoutTabId: 'tab-1',
gridPosition: { row: 0, column: 0, rowSpan: 1, columnSpan: 1 },
configuration: {},
...overrides,
}) as PageLayoutWidget;
const makeDynamicRelationWidget = (suffix: string): PageLayoutWidget =>
makeWidget({
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}${suffix}`,
type: WidgetType.VIEW,
title: `Dynamic Relation ${suffix}`,
});
const makePersistedLayout = (tabs: PageLayout['tabs']): PageLayout =>
({
id: 'layout-1',
name: 'Layout',
type: 'RECORD_PAGE',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
tabs,
}) as PageLayout;
const makeDraftLayout = (tabs: DraftPageLayout['tabs']): DraftPageLayout =>
({
id: 'layout-1',
name: 'Layout',
type: 'RECORD_PAGE',
tabs,
}) as DraftPageLayout;
const makeTab = (
id: string,
widgets: PageLayoutWidget[],
): PageLayout['tabs'][number] =>
({
id,
title: 'Tab',
position: 0,
pageLayoutId: 'layout-1',
applicationId: 'app-1',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
widgets,
}) as PageLayout['tabs'][number];
describe('reInjectDynamicRelationWidgetsFromDraft', () => {
it('should return persisted layout unchanged when draft has no dynamic widgets', () => {
const persistedLayout = makePersistedLayout([
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
);
expect(result).toBe(persistedLayout);
});
it('should inject dynamic widgets after the first FIELDS widget', () => {
const fieldsWidget = makeWidget({
id: 'fields-1',
type: WidgetType.FIELDS,
});
const timelineWidget = makeWidget({
id: 'timeline-1',
type: WidgetType.TIMELINE,
});
const dynamicWidget = makeDynamicRelationWidget('relation-1');
const persistedLayout = makePersistedLayout([
makeTab('tab-1', [fieldsWidget, timelineWidget]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget, dynamicWidget, timelineWidget]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toBe(
`${DYNAMIC_RELATION_WIDGET_ID_PREFIX}relation-1`,
);
expect(result.tabs[0].widgets[2].id).toBe('timeline-1');
});
it('should append dynamic widgets if no FIELDS widget exists in the tab', () => {
const timelineWidget = makeWidget({
id: 'timeline-1',
type: WidgetType.TIMELINE,
});
const dynamicWidget = makeDynamicRelationWidget('relation-1');
const persistedLayout = makePersistedLayout([
makeTab('tab-1', [timelineWidget]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [timelineWidget, dynamicWidget]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[0].widgets[0].id).toBe('timeline-1');
expect(result.tabs[0].widgets[1].id).toBe(
`${DYNAMIC_RELATION_WIDGET_ID_PREFIX}relation-1`,
);
});
it('should handle multiple tabs independently', () => {
const fieldsWidget1 = makeWidget({
id: 'fields-1',
type: WidgetType.FIELDS,
});
const fieldsWidget2 = makeWidget({
id: 'fields-2',
type: WidgetType.FIELDS,
});
const dynamicWidget = makeDynamicRelationWidget('rel-1');
const persistedLayout = makePersistedLayout([
makeTab('tab-1', [fieldsWidget1]),
makeTab('tab-2', [fieldsWidget2]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget1, dynamicWidget]),
makeTab('tab-2', [fieldsWidget2]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
);
// Tab 1 should have dynamic widget injected
expect(result.tabs[0].widgets).toHaveLength(2);
// Tab 2 should remain unchanged
expect(result.tabs[1].widgets).toHaveLength(1);
});
it('should inject multiple dynamic widgets', () => {
const fieldsWidget = makeWidget({
id: 'fields-1',
type: WidgetType.FIELDS,
});
const dynamicWidget1 = makeDynamicRelationWidget('rel-1');
const dynamicWidget2 = makeDynamicRelationWidget('rel-2');
const persistedLayout = makePersistedLayout([
makeTab('tab-1', [fieldsWidget]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget, dynamicWidget1, dynamicWidget2]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(
persistedLayout,
draft,
);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toBe(
`${DYNAMIC_RELATION_WIDGET_ID_PREFIX}rel-1`,
);
expect(result.tabs[0].widgets[2].id).toBe(
`${DYNAMIC_RELATION_WIDGET_ID_PREFIX}rel-2`,
);
});
});
@@ -14,61 +14,13 @@ describe('shouldEnableTabEditingFeatures', () => {
expect(result).toBe(true);
});
it('should return false for RECORD_PAGE layout type without flag', () => {
it('should return true for RECORD_PAGE layout type', () => {
const result = shouldEnableTabEditingFeatures(PageLayoutType.RECORD_PAGE);
expect(result).toBe(false);
});
it('should return true for RECORD_PAGE layout type with flag enabled', () => {
const result = shouldEnableTabEditingFeatures(
PageLayoutType.RECORD_PAGE,
true,
);
expect(result).toBe(true);
});
it('should return false for RECORD_PAGE layout type with flag disabled', () => {
const result = shouldEnableTabEditingFeatures(
PageLayoutType.RECORD_PAGE,
false,
);
expect(result).toBe(false);
});
it('should return false for RECORD_INDEX layout type', () => {
const result = shouldEnableTabEditingFeatures(PageLayoutType.RECORD_INDEX);
expect(result).toBe(false);
});
it('should return false for RECORD_INDEX layout type even with flag', () => {
const result = shouldEnableTabEditingFeatures(
PageLayoutType.RECORD_INDEX,
true,
);
expect(result).toBe(false);
});
describe('behavior validation', () => {
it('should enable tab editing features only for dashboards, standalone pages, and record pages with flag', () => {
expect(shouldEnableTabEditingFeatures(PageLayoutType.DASHBOARD)).toBe(
true,
);
expect(
shouldEnableTabEditingFeatures(PageLayoutType.STANDALONE_PAGE),
).toBe(true);
expect(shouldEnableTabEditingFeatures(PageLayoutType.RECORD_PAGE)).toBe(
false,
);
expect(
shouldEnableTabEditingFeatures(PageLayoutType.RECORD_PAGE, true),
).toBe(true);
expect(shouldEnableTabEditingFeatures(PageLayoutType.RECORD_INDEX)).toBe(
false,
);
});
});
});
@@ -1,6 +1,5 @@
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWidget';
import {
PageLayoutTabLayoutMode,
type UpdatePageLayoutWithTabsInput,
@@ -40,10 +39,7 @@ const buildWidgetPosition = (
export const convertPageLayoutDraftToUpdateInput = (
pageLayoutDraft: DraftPageLayout,
options?: { shouldFilterDynamicRelationWidgets?: boolean },
): UpdatePageLayoutWithTabsInput => {
const shouldFilter = options?.shouldFilterDynamicRelationWidgets ?? false;
return {
name: pageLayoutDraft.name,
type: pageLayoutDraft.type,
@@ -51,17 +47,13 @@ export const convertPageLayoutDraftToUpdateInput = (
tabs: pageLayoutDraft.tabs
.filter((tab) => tab.isActive)
.map((tab) => {
const widgets = shouldFilter
? tab.widgets.filter((widget) => !isDynamicRelationWidget(widget))
: tab.widgets;
return {
id: tab.id,
title: tab.title,
position: tab.position,
icon: tab.icon ?? null,
layoutMode: tab.layoutMode,
widgets: widgets.map((widget, widgetIndex) => ({
widgets: tab.widgets.map((widget, widgetIndex) => ({
id: widget.id,
pageLayoutTabId: widget.pageLayoutTabId,
title: widget.title,
@@ -1,131 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { DYNAMIC_RELATION_WIDGET_ID_PREFIX } from '@/page-layout/utils/isDynamicRelationWidget';
import { isDefined } from 'twenty-shared/utils';
import {
FieldDisplayMode,
PageLayoutTabLayoutMode,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
const getRelationFieldWidgetToInsert = (
field: FieldMetadataItem,
tabId: string,
): PageLayoutWidget => ({
__typename: 'PageLayoutWidget' as const,
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}${field.id}-${field.label}`,
applicationId: '',
pageLayoutTabId: tabId,
title: field.label,
isActive: true,
type: WidgetType.FIELD,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition' as const,
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetGridPosition' as const,
layoutMode: PageLayoutTabLayoutMode.GRID,
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'FieldConfiguration' as const,
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: field.id,
fieldDisplayMode: FieldDisplayMode.CARD,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
});
const getRelationFieldWidgetsToInsert = (
relationFields: FieldMetadataItem[],
tabId: string,
): PageLayoutWidget[] => {
return relationFields.map((field) =>
getRelationFieldWidgetToInsert(field, tabId),
);
};
export const injectRelationWidgetsIntoLayout = (
layout: PageLayout,
boxedRelationFieldMetadataItems: FieldMetadataItem[],
): PageLayout => {
if (boxedRelationFieldMetadataItems.length === 0) {
return layout;
}
const firstTab = layout.tabs[0];
if (!isDefined(firstTab)) {
return layout;
}
const relationWidgets = getRelationFieldWidgetsToInsert(
boxedRelationFieldMetadataItems,
firstTab.id,
);
return {
...layout,
tabs: layout.tabs.map((tab) => {
if (tab.id === firstTab.id) {
const firstFieldsWidgetIndex = tab.widgets.findIndex(
(widget) => widget.type === WidgetType.FIELDS,
);
if (firstFieldsWidgetIndex === -1) {
return {
...tab,
widgets: [...tab.widgets, ...relationWidgets],
};
}
// TODO: This note widget repositioning logic is temporary and will be deleted soon.
// We need this to ensure the note editor is displayed before record relations,
// matching the behavior of the old show page.
const noteWidgetIndex = tab.widgets.findIndex(
(widget) => widget.type === WidgetType.NOTES,
);
const widgetsBeforeRelation = tab.widgets.slice(
0,
firstFieldsWidgetIndex + 1,
);
const widgetsAfterRelation =
noteWidgetIndex === -1
? tab.widgets.slice(firstFieldsWidgetIndex + 1)
: [
...tab.widgets.slice(
firstFieldsWidgetIndex + 1,
noteWidgetIndex,
),
...tab.widgets.slice(noteWidgetIndex + 1),
];
const noteWidget =
noteWidgetIndex !== -1 ? [tab.widgets[noteWidgetIndex]] : [];
return {
...tab,
widgets: [
...widgetsBeforeRelation,
...relationWidgets,
...noteWidget,
...widgetsAfterRelation,
],
};
}
return tab;
}),
};
};
@@ -1,7 +0,0 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
export const DYNAMIC_RELATION_WIDGET_ID_PREFIX = 'dynamic-relation-widget-';
export const isDynamicRelationWidget = (widget: PageLayoutWidget): boolean => {
return widget.id.startsWith(DYNAMIC_RELATION_WIDGET_ID_PREFIX);
};
@@ -1,54 +0,0 @@
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWidget';
import { WidgetType } from '~/generated-metadata/graphql';
export const reInjectDynamicRelationWidgetsFromDraft = (
persistedLayout: PageLayout,
previousDraft: DraftPageLayout,
): PageLayout => {
const dynamicWidgetsByTabId = new Map(
previousDraft.tabs.map((tab) => [
tab.id,
tab.widgets.filter(isDynamicRelationWidget),
]),
);
const hasDynamicWidgets = [...dynamicWidgetsByTabId.values()].some(
(widgets) => widgets.length > 0,
);
if (!hasDynamicWidgets) {
return persistedLayout;
}
return {
...persistedLayout,
tabs: persistedLayout.tabs.map((tab) => {
const relationWidgets = dynamicWidgetsByTabId.get(tab.id);
if (!relationWidgets || relationWidgets.length === 0) {
return tab;
}
const firstFieldsWidgetIndex = tab.widgets.findIndex(
(widget) => widget.type === WidgetType.FIELDS,
);
if (firstFieldsWidgetIndex === -1) {
return {
...tab,
widgets: [...tab.widgets, ...relationWidgets],
};
}
const widgetsBefore = tab.widgets.slice(0, firstFieldsWidgetIndex + 1);
const widgetsAfter = tab.widgets.slice(firstFieldsWidgetIndex + 1);
return {
...tab,
widgets: [...widgetsBefore, ...relationWidgets, ...widgetsAfter],
};
}),
};
};
@@ -2,21 +2,10 @@ import { PageLayoutType } from '~/generated-metadata/graphql';
export const shouldEnableTabEditingFeatures = (
pageLayoutType: PageLayoutType,
isRecordPageGlobalEditionEnabled?: boolean,
): boolean => {
if (
return (
pageLayoutType === PageLayoutType.DASHBOARD ||
pageLayoutType === PageLayoutType.STANDALONE_PAGE
) {
return true;
}
if (
pageLayoutType === PageLayoutType.RECORD_PAGE &&
isRecordPageGlobalEditionEnabled
) {
return true;
}
return false;
pageLayoutType === PageLayoutType.STANDALONE_PAGE ||
pageLayoutType === PageLayoutType.RECORD_PAGE
);
};
@@ -2,18 +2,6 @@ import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { WidgetCardShell } from '@/page-layout/widgets/components/WidgetCardShell';
import { useWidgetActions } from '@/page-layout/widgets/hooks/useWidgetActions';
import { useWidgetRendererState } from '@/page-layout/widgets/hooks/useWidgetRendererState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
PageLayoutType,
WidgetType,
} from '~/generated-metadata/graphql';
const StyledEditingWidgetWrapper = styled.div`
padding: ${themeCssVariables.spacing[2]};
`;
type RecordPageWidgetRendererProps = {
widget: PageLayoutWidget;
@@ -24,39 +12,13 @@ export const RecordPageWidgetRenderer = ({
}: RecordPageWidgetRendererProps) => {
const state = useWidgetRendererState(widget);
const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
);
const isRecordPageLayout =
state.currentPageLayout.type === PageLayoutType.RECORD_PAGE;
const isReorderEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isDeletingWidgetEnabled =
!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled);
const isWidgetEditable =
state.isPageLayoutInEditMode &&
(!isRecordPageLayout ||
(isRecordPageLayout && isRecordPageGlobalEditionEnabled) ||
widget.type === WidgetType.FIELDS ||
widget.type === WidgetType.FIELD);
const isWidgetEditable = state.isPageLayoutInEditMode;
const actions = useWidgetActions({ widget });
// TODO: remove once all record page layouts widgets use the editable contain in edit mode
const shouldWrapWithEditingWrapper =
isWidgetEditable &&
state.variant === 'side-column' &&
!isRecordPageGlobalEditionEnabled;
const isCanvasVariant = state.variant === 'canvas';
const shell = (
return (
<WidgetCardShell
widget={widget}
variant={state.variant}
@@ -71,18 +33,12 @@ export const RecordPageWidgetRenderer = ({
actions={actions}
isInVerticalListTab={state.isInVerticalListTab}
isMobile={state.isMobile}
isReorderEnabled={isReorderEnabled}
isDeletingWidgetEnabled={isDeletingWidgetEnabled}
isReorderEnabled={true}
isDeletingWidgetEnabled={true}
onClick={isWidgetEditable ? state.handleClick : undefined}
onRemove={state.handleRemove}
onMouseEnter={isCanvasVariant ? undefined : state.handleMouseEnter}
onMouseLeave={isCanvasVariant ? undefined : state.handleMouseLeave}
/>
);
if (shouldWrapWithEditingWrapper) {
return <StyledEditingWidgetWrapper>{shell}</StyledEditingWidgetWrapper>;
}
return shell;
};
@@ -81,9 +81,6 @@ export const SettingsObjectDetailPage = () => {
const isUniqueIndexesEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_UNIQUE_INDEXES_ENABLED,
);
const isRecordPageLayoutEditingEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
);
const [isDeleting, setIsDeleting] = useState(false);
@@ -123,7 +120,6 @@ export const SettingsObjectDetailPage = () => {
title: t`Layout`,
Icon: IconLayout,
hide:
!isRecordPageLayoutEditingEnabled ||
objectMetadataItem.isRemote ||
objectMetadataItem.nameSingular === CoreObjectNameSingular.Dashboard,
},
@@ -1,5 +1,5 @@
import { Command } from 'nest-commander';
import { FeatureFlagKey, ViewType } from 'twenty-shared/types';
import { type FeatureFlagKey, ViewType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner';
@@ -47,7 +47,7 @@ export class BackfillRecordPageLayoutsCommand extends ActiveOrSuspendedWorkspace
const isDryRun = options.dryRun ?? false;
const isAlreadyEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as FeatureFlagKey,
workspaceId,
);
@@ -88,12 +88,12 @@ export class BackfillRecordPageLayoutsCommand extends ActiveOrSuspendedWorkspace
});
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED],
['IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' as FeatureFlagKey],
workspaceId,
);
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED],
['IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' as FeatureFlagKey],
workspaceId,
);
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FindOneOptions, type Repository } from 'typeorm';
@@ -378,7 +377,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewMaps: existingFlatViewMaps,
flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps,
featureFlagsMap: existingFeatureFlagsMap,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
@@ -386,7 +384,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
'flatViewFieldMaps',
'flatViewMaps',
'flatViewFieldGroupMaps',
'featureFlagsMap',
]);
const allTranspiledTranspilationInputs: Awaited<
@@ -423,15 +420,8 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
{ flatFieldMetadatas: [], indexMetadatas: [] },
);
let flatViewFieldsToCreate: UniversalFlatViewField[] = [];
if (
existingFeatureFlagsMap[
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
] ??
false
) {
flatViewFieldsToCreate = computeFlatViewFieldsFromFieldsWidgets({
const flatViewFieldsToCreate: UniversalFlatViewField[] =
computeFlatViewFieldsFromFieldsWidgets({
fieldsToCreate: flatFieldMetadatasToCreate.map((flatFieldMetadata) => ({
objectMetadataUniversalIdentifier:
flatFieldMetadata.objectMetadataUniversalIdentifier,
@@ -445,7 +435,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
applicationUniversalIdentifier:
resolvedOwnerFlatApplication.universalIdentifier,
});
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
@@ -3,7 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import {
FeatureFlagKey,
ViewKey,
ViewOpenRecordIn,
ViewType,
@@ -475,13 +474,10 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const resolvedOwnerFlatApplication =
ownerFlatApplication ?? workspaceCustomFlatApplication;
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
featureFlagsMap: existingFeatureFlagsMap,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'featureFlagsMap',
]);
const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
const {
flatObjectMetadataToCreate,
@@ -507,11 +503,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
viewUniversalIdentifier: flatDefaultViewToCreate.universalIdentifier,
});
const isRecordPageLayoutEditingEnabled =
existingFeatureFlagsMap[
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
] ?? false;
const flatNavigationMenuItemToCreate =
await this.computeFlatNavigationMenuItemToCreate({
objectMetadata: flatObjectMetadataToCreate,
@@ -617,77 +608,75 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
if (isRecordPageLayoutEditingEnabled) {
const flatRecordPageFieldsViewToCreate =
this.computeFlatRecordPageFieldsViewToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
});
const flatRecordPageFieldsViewToCreate =
this.computeFlatRecordPageFieldsViewToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
});
const flatRecordPageFieldsViewFieldsToCreate =
computeFlatViewFieldsToCreate({
flatApplication: twentyStandardFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier:
flatRecordPageFieldsViewToCreate.universalIdentifier,
excludeLabelIdentifier: true,
});
const flatRecordPageFieldsViewFieldsToCreate =
computeFlatViewFieldsToCreate({
flatApplication: twentyStandardFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier:
flatRecordPageFieldsViewToCreate.universalIdentifier,
excludeLabelIdentifier: true,
});
const flatDefaultRecordPageLayoutsToCreate =
this.computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
workspaceId,
});
const flatDefaultRecordPageLayoutsToCreate =
this.computeFlatDefaultRecordPageLayoutToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: twentyStandardFlatApplication,
recordPageFieldsView: flatRecordPageFieldsViewToCreate,
workspaceId,
});
const pageLayoutMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
view: {
flatEntityToCreate: [flatRecordPageFieldsViewToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatRecordPageFieldsViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayout: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
const pageLayoutMigrationResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
view: {
flatEntityToCreate: [flatRecordPageFieldsViewToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatRecordPageFieldsViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayout: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayouts,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutTab: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutTabs,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
pageLayoutWidget: {
flatEntityToCreate:
flatDefaultRecordPageLayoutsToCreate.pageLayoutWidgets,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (pageLayoutMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
pageLayoutMigrationResult,
'Multiple validation errors occurred while creating page layouts for object',
);
}
if (pageLayoutMigrationResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
pageLayoutMigrationResult,
'Multiple validation errors occurred while creating page layouts for object',
);
}
const { flatObjectMetadataMaps: recomputedFlatObjectMetadataMaps } =
@@ -233,12 +233,10 @@ describe('WorkspaceEntityManager', () => {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: false,
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAILING_DOMAIN_ENABLED: false,
IS_EMAIL_GROUP_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
},
userWorkspaceRoleMap: {},
@@ -50,16 +50,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
workspaceId: workspaceId,
value: true,
},
])
.execute();
};
@@ -264,7 +264,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = {
shortLabel: 'Edit Layout',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
conditionalAvailabilityExpression:
'pageType == "RECORD_PAGE" and featureFlags.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED and not isLayoutCustomizationModeEnabled and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"',
'pageType == "RECORD_PAGE" and not isLayoutCustomizationModeEnabled and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"',
availabilityObjectMetadataUniversalIdentifier: null,
frontComponentUniversalIdentifier: null,
engineComponentKey: EngineComponentKey.EDIT_RECORD_PAGE_LAYOUT,
@@ -1,7 +1,5 @@
import { FeatureFlagKey } from 'twenty-shared/types';
export const DEFAULT_FEATURE_FLAGS = [
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED,
FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED,
FeatureFlagKey.IS_REST_METADATA_API_NEW_FORMAT_DIRECT,
] as const satisfies FeatureFlagKey[];
@@ -2,11 +2,9 @@ export enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED = 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED = 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
}