Baptiste Devessier
2026-02-20 14:18:00 +01:00
committed by GitHub
parent b107020ad3
commit 00209f7e2c
136 changed files with 4875 additions and 835 deletions
File diff suppressed because one or more lines are too long
@@ -1,5 +1,4 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
@@ -7,12 +6,9 @@ import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hook
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const CancelRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
@@ -1,17 +1,13 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
@@ -1,22 +1,22 @@
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
export const SaveRecordPageLayoutSingleRecordAction = () => {
const recordId = useSelectedRecordIdOrThrow();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups({
pageLayoutId,
});
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
@@ -27,6 +27,8 @@ export const SaveRecordPageLayoutSingleRecordAction = () => {
const result = await savePageLayout();
if (result.status === 'successful') {
await saveFieldsWidgetGroups();
closeCommandMenu();
setIsPageLayoutInEditMode(false);
}
@@ -3,6 +3,7 @@ import { useRecoilCallback } from 'recoil';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
@@ -69,6 +70,13 @@ export const useExecuteTasksOnAnyLocationChange = () => {
false,
);
set(
hasInitializedFieldsWidgetGroupsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
set(currentPageLayoutIdState, null);
}
@@ -3,12 +3,15 @@ import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/co
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 { FieldsWidgetGroupsDraftInitializationEffect } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
const StyledOuterContainer = styled.div`
display: flex;
@@ -28,25 +31,23 @@ const StyledContainer = styled.div`
export const CommandMenuPageLayoutFieldsLayout = () => {
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { pageLayoutId, objectNameSingular } =
const { pageLayoutId } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const defaultFieldsConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
const [fieldsConfiguration, setFieldsConfiguration] =
useState<FieldsConfiguration>(defaultFieldsConfiguration);
const temporaryFieldsConfiguration = useTemporaryFieldsConfiguration();
if (!isDefined(widgetInEditMode)) {
return null;
}
const handleConfigurationChange = (
updatedConfiguration: FieldsConfiguration,
) => {
// TODO: replace with a call to updatePageLayoutWidget
setFieldsConfiguration(updatedConfiguration);
};
const widgetConfiguration = widgetInEditMode.configuration;
const fieldsConfiguration: FieldsConfiguration =
isDefined(widgetConfiguration) &&
widgetConfiguration.configurationType === WidgetConfigurationType.FIELDS
? (widgetConfiguration as FieldsConfiguration)
: temporaryFieldsConfiguration;
return (
<StyledOuterContainer>
@@ -55,9 +56,14 @@ export const CommandMenuPageLayoutFieldsLayout = () => {
onBackClick={goBackFromCommandMenu}
/>
<StyledContainer>
<FieldsWidgetGroupsDraftInitializationEffect
viewId={fieldsConfiguration.viewId ?? null}
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
/>
<FieldsConfigurationEditor
configuration={fieldsConfiguration}
onChange={handleConfigurationChange}
pageLayoutId={pageLayoutId}
widgetId={widgetInEditMode.id}
/>
</StyledContainer>
</StyledOuterContainer>
@@ -6,12 +6,13 @@ import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layo
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 { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
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';
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
display: flex;
@@ -33,15 +34,22 @@ export const CommandMenuPageLayoutFieldsSettings = () => {
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldsConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
const fieldsConfiguration = widgetInEditMode?.configuration as
| FieldsConfiguration
| undefined;
const { groups } = useFieldsWidgetGroups({
viewId: fieldsConfiguration?.viewId ?? null,
objectNameSingular,
});
if (!isDefined(widgetInEditMode)) {
return null;
}
const totalFieldsCount = fieldsConfiguration.sections.reduce(
(count, section) => count + section.fields.length,
const totalFieldsCount = groups.reduce(
(count, group) => count + group.fields.length,
0,
);
@@ -5,7 +5,6 @@ import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/
import { getDateGranularityLabel } from '@/command-menu/pages/page-layout/utils/getDateGranularityLabel';
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -17,7 +16,10 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/jotai/hooks/useR
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type WidgetConfiguration } from '~/generated-metadata/graphql';
import {
type FieldsConfiguration,
type WidgetConfiguration,
} from '~/generated-metadata/graphql';
type ChartDateGranularitySelectionDropdownContentProps = {
axis?: 'primary' | 'secondary';
@@ -32,10 +32,7 @@ export const usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord =
throw new Error('Only one record should be selected');
}
const recordId: string = targetedRecordsRule.selectedRecordIds[0];
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
id: recordId,
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
@@ -1,13 +1,9 @@
import { type ChartWidget } from '@/command-menu/pages/page-layout/types/ChartWidget';
import { isWidgetConfigurationOfTypeGraph } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfTypeGraph';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { type WidgetConfiguration } from '~/generated-metadata/graphql';
export const isChartWidget = (
pageLayoutWidget: PageLayoutWidget,
): pageLayoutWidget is ChartWidget => {
return isWidgetConfigurationOfTypeGraph(
// TODO: Remove this cast when we FieldsConfiguration and FieldConfiguration are in the backend
pageLayoutWidget.configuration as WidgetConfiguration,
);
return isWidgetConfigurationOfTypeGraph(pageLayoutWidget.configuration);
};
@@ -1,11 +1,11 @@
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import {
type AggregateChartConfiguration,
type BarChartConfiguration,
type CalendarConfiguration,
type EmailsConfiguration,
type FieldRichTextConfiguration,
type FieldsConfiguration,
type FilesConfiguration,
type FrontComponentConfiguration,
type GaugeChartConfiguration,
@@ -1,8 +1,10 @@
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { type WidgetConfiguration } from '~/generated-metadata/graphql';
import {
type FieldsConfiguration,
type WidgetConfiguration,
} from '~/generated-metadata/graphql';
export const isWidgetConfigurationOfTypeGraph = (
configuration:
@@ -50,13 +50,15 @@ const renderHooks = ({
isCompact: false,
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
viewFields: [],
viewFieldGroups: [],
viewGroups: [],
viewSorts: [],
viewFilters: [],
viewFilterGroups: [],
kanbanAggregateOperation: AggregateOperations.COUNT,
icon: '',
kanbanAggregateOperationFieldMetadataId: '',
position: 0,
viewFilters: [],
visibility: ViewVisibility.WORKSPACE,
createdByUserWorkspaceId: null,
shouldHideEmptyGroups: false,
@@ -149,6 +149,9 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
updateCurrentViewParams.mainGroupByFieldMetadataId = null;
return await updateCurrentView(updateCurrentViewParams);
}
case ViewType.FieldsWidget: {
return;
}
default: {
return assertUnreachable(viewType);
}
@@ -6,7 +6,7 @@ import { RecordShowContainerContextStoreTargetedRecordsEffect } from '@/object-r
import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { useRecordPageLayoutId } from '@/page-layout/hooks/useRecordPageLayoutId';
import { usePageLayoutIdForRecord } from '@/page-layout/hooks/usePageLayoutIdForRecord';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
@@ -50,7 +50,7 @@ export const PageLayoutRecordPageRenderer = ({
}),
);
const { pageLayoutId } = useRecordPageLayoutId({
const { pageLayoutId } = usePageLayoutIdForRecord({
id: targetRecordIdentifier.id,
targetObjectNameSingular: targetRecordIdentifier.targetObjectNameSingular,
});
@@ -0,0 +1,43 @@
import { getPageLayoutVerticalListViewerVariant } from '@/page-layout/components/utils/getPageLayoutVerticalListViewerVariant';
describe('getPageLayoutVerticalListViewerVariant', () => {
it('should return side-column when isInPinnedTab is true', () => {
expect(
getPageLayoutVerticalListViewerVariant({
isInPinnedTab: true,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('side-column');
});
it('should return side-column when isMobile is true', () => {
expect(
getPageLayoutVerticalListViewerVariant({
isInPinnedTab: false,
isMobile: true,
isInRightDrawer: false,
}),
).toBe('side-column');
});
it('should return side-column when isInRightDrawer is true', () => {
expect(
getPageLayoutVerticalListViewerVariant({
isInPinnedTab: false,
isMobile: false,
isInRightDrawer: true,
}),
).toBe('side-column');
});
it('should return default when none of the conditions are true', () => {
expect(
getPageLayoutVerticalListViewerVariant({
isInPinnedTab: false,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('default');
});
});
@@ -47,7 +47,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -85,7 +85,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -123,7 +123,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -161,7 +161,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -199,7 +199,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -237,7 +237,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -275,7 +275,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -48,8 +49,8 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
// a configuration with actual field metadata IDs from the backend.
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -71,8 +72,8 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -116,8 +117,8 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -161,8 +162,8 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -199,8 +200,8 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -47,7 +47,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -85,7 +85,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -123,7 +123,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -161,7 +161,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -199,7 +199,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -237,7 +237,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -275,7 +275,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -45,8 +46,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -83,8 +84,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -121,8 +122,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -159,8 +160,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -197,8 +198,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -235,8 +236,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -273,8 +274,8 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -45,8 +46,8 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -83,8 +84,8 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -121,8 +122,8 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -159,8 +160,8 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -197,8 +198,8 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -45,8 +46,8 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -68,8 +69,8 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -113,8 +114,8 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -158,8 +159,8 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -196,8 +197,8 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -50,8 +51,8 @@ export const DEFAULT_WORKFLOW_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -51,8 +52,8 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -90,8 +91,8 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -3,6 +3,7 @@ import { type PageLayout } from '@/page-layout/types/PageLayout';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
@@ -51,8 +52,8 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -90,8 +91,8 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -165,6 +165,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
}
... on FieldsConfiguration {
configurationType
viewId
}
... on FilesConfiguration {
configurationType
@@ -1,9 +1,8 @@
import { PAGE_LAYOUT_TAB_FRAGMENT } from '@/dashboards/graphql/fragments/pageLayoutTabFragment';
import { gql } from '@apollo/client';
import { PAGE_LAYOUT_WIDGET_FRAGMENT } from '@/page-layout/graphql/fragments/pageLayoutWidgetFragment';
export const UPDATE_PAGE_LAYOUT_WITH_TABS_AND_WIDGETS = gql`
${PAGE_LAYOUT_WIDGET_FRAGMENT}
${PAGE_LAYOUT_TAB_FRAGMENT}
mutation UpdatePageLayoutWithTabsAndWidgets(
$id: String!
$input: UpdatePageLayoutWithTabsInput!
@@ -13,20 +12,12 @@ export const UPDATE_PAGE_LAYOUT_WITH_TABS_AND_WIDGETS = gql`
name
type
objectMetadataId
defaultTabToFocusOnMobileAndSidePanelId
createdAt
updatedAt
deletedAt
tabs {
id
applicationId
title
position
pageLayoutId
widgets {
...PageLayoutWidgetFragment
}
createdAt
updatedAt
...PageLayoutTabFragment
}
}
}
@@ -60,7 +60,7 @@ describe('usePageLayoutWithRelationWidgets', () => {
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
sections: [],
viewId: null,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -0,0 +1,51 @@
import { useRecoilValue } from 'recoil';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { isDefined } from 'twenty-shared/utils';
export const usePageLayoutIdForRecord = ({
id,
targetObjectNameSingular,
}: TargetRecordIdentifier) => {
const isDashboard =
targetObjectNameSingular === CoreObjectNameSingular.Dashboard;
const { record } = useFindOneRecord<ObjectRecord & { pageLayoutId?: string }>(
{
objectNameSingular: targetObjectNameSingular,
objectRecordId: id,
withSoftDeleted: true,
skip: !isDashboard,
},
);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetObjectNameSingular,
});
const recordPageLayout = useRecoilValue(
recordPageLayoutByObjectMetadataIdFamilySelector({
objectMetadataId: objectMetadataItem.id,
}),
);
if (isDashboard) {
return {
pageLayoutId: record?.pageLayoutId ?? null,
};
}
const pageLayoutId = isDefined(recordPageLayout)
? recordPageLayout.id
: getDefaultRecordPageLayoutId({ targetObjectNameSingular });
return {
pageLayoutId,
};
};
@@ -1,26 +0,0 @@
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { getRecordPageLayoutId } from '@/page-layout/utils/getRecordPageLayoutId';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
export const useRecordPageLayoutId = ({
id,
targetObjectNameSingular,
}: TargetRecordIdentifier) => {
const { record } = useFindOneRecord<ObjectRecord & { pageLayoutId?: string }>(
{
objectNameSingular: targetObjectNameSingular,
objectRecordId: id,
withSoftDeleted: true,
},
);
const pageLayoutId = getRecordPageLayoutId({
record,
targetObjectNameSingular,
});
return {
pageLayoutId,
};
};
@@ -1,27 +1,26 @@
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { getRecordPageLayoutId } from '@/page-layout/utils/getRecordPageLayoutId';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
import { type TargetRecordIdentifier } from '@/ui/layout/contexts/TargetRecordIdentifier';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useRecordPageLayoutIdFromRecordStoreOrThrow = ({
id,
targetObjectNameSingular,
}: TargetRecordIdentifier) => {
const record = useRecoilValue(recordStoreFamilyState(id));
if (!isDefined(record)) {
throw new Error(`Record with id ${id} not found in record store`);
}
const pageLayoutId = getRecordPageLayoutId({
record,
targetObjectNameSingular,
}: Omit<TargetRecordIdentifier, 'id'>) => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetObjectNameSingular,
});
if (!isDefined(pageLayoutId)) {
throw new Error(`Page layout id not found for record with id ${id}`);
}
const recordPageLayout = useRecoilValue(
recordPageLayoutByObjectMetadataIdFamilySelector({
objectMetadataId: objectMetadataItem.id,
}),
);
const pageLayoutId = isDefined(recordPageLayout)
? recordPageLayout.id
: getDefaultRecordPageLayoutId({ targetObjectNameSingular });
return {
pageLayoutId,
@@ -1,4 +1,6 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
@@ -42,6 +44,16 @@ export const useResetDraftPageLayoutToPersistedPageLayout = (
instanceId: tabListComponentInstanceId,
});
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
componentInstanceId,
);
const fieldsWidgetGroupsPersistedState = useRecoilComponentCallbackState(
fieldsWidgetGroupsPersistedComponentState,
componentInstanceId,
);
const resetDraftPageLayoutToPersistedPageLayout = useRecoilCallback(
({ set, snapshot }) =>
() => {
@@ -73,12 +85,19 @@ export const useResetDraftPageLayoutToPersistedPageLayout = (
const tabLayouts = convertPageLayoutToTabLayouts(pageLayoutPersisted);
set(pageLayoutCurrentLayoutsState, tabLayouts);
const fieldsWidgetGroupsPersisted = snapshot
.getLoadable(fieldsWidgetGroupsPersistedState)
.getValue();
set(fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersisted);
}
},
[
pageLayoutDraftState,
pageLayoutPersistedState,
pageLayoutCurrentLayoutsState,
fieldsWidgetGroupsDraftState,
fieldsWidgetGroupsPersistedState,
activeTabIdAtom,
store,
],
@@ -0,0 +1,185 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { computeFieldsWidgetFieldDiff } from '@/page-layout/utils/computeFieldsWidgetFieldDiff';
import { computeFieldsWidgetGroupDiff } from '@/page-layout/utils/computeFieldsWidgetGroupDiff';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { usePerformViewFieldGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFieldGroupAPIPersist';
import { useRefreshAllCoreViews } from '@/views/hooks/useRefreshAllCoreViews';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
type UseSaveFieldsWidgetGroupsParams = {
pageLayoutId: string;
};
export const useSaveFieldsWidgetGroups = ({
pageLayoutId,
}: UseSaveFieldsWidgetGroupsParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetGroupsPersistedState = useRecoilComponentCallbackState(
fieldsWidgetGroupsPersistedComponentState,
pageLayoutId,
);
const pageLayoutPersistedState = useRecoilComponentCallbackState(
pageLayoutPersistedComponentState,
pageLayoutId,
);
const {
performViewFieldGroupAPICreate,
performViewFieldGroupAPIUpdate,
performViewFieldGroupAPIDelete,
} = usePerformViewFieldGroupAPIPersist();
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
const { refreshAllCoreViews } = useRefreshAllCoreViews();
const getViewIdForWidget = useRecoilCallback(
({ snapshot }) =>
(widgetId: string): string | null => {
const pageLayoutPersisted = snapshot
.getLoadable(pageLayoutPersistedState)
.getValue();
if (!isDefined(pageLayoutPersisted)) {
return null;
}
for (const tab of pageLayoutPersisted.tabs) {
for (const widget of tab.widgets) {
if (
widget.id === widgetId &&
isDefined(widget.configuration) &&
widget.configuration.configurationType ===
WidgetConfigurationType.FIELDS
) {
return (
(widget.configuration as FieldsConfiguration).viewId ?? null
);
}
}
}
return null;
},
[pageLayoutPersistedState],
);
const saveFieldsWidgetGroups = useRecoilCallback(
({ set, snapshot }) =>
async () => {
const allDraftGroups = snapshot
.getLoadable(fieldsWidgetGroupsDraftState)
.getValue();
const allPersistedGroups = snapshot
.getLoadable(fieldsWidgetGroupsPersistedState)
.getValue();
const widgetIds = new Set([
...Object.keys(allDraftGroups),
...Object.keys(allPersistedGroups),
]);
for (const widgetId of widgetIds) {
const draftGroups = allDraftGroups[widgetId] ?? [];
const persistedGroups = allPersistedGroups[widgetId] ?? [];
if (draftGroups.length === 0 && persistedGroups.length === 0) {
continue;
}
const viewId = getViewIdForWidget(widgetId);
if (!isDefined(viewId)) {
continue;
}
const { createdGroups, deletedGroups, updatedGroups } =
computeFieldsWidgetGroupDiff(persistedGroups, draftGroups);
if (createdGroups.length > 0) {
await performViewFieldGroupAPICreate({
inputs: createdGroups.map((group) => ({
id: group.id,
name: group.name,
position: group.position,
isVisible: group.isVisible,
viewId,
})),
});
}
if (deletedGroups.length > 0) {
for (const group of deletedGroups) {
await performViewFieldGroupAPIDelete([
{ input: { id: group.id } },
]);
}
}
if (updatedGroups.length > 0) {
const updates = updatedGroups.map((group) => ({
input: {
id: group.id,
update: {
name: group.name,
position: group.position,
isVisible: group.isVisible,
},
},
}));
await performViewFieldGroupAPIUpdate(updates);
}
const fieldUpdates = computeFieldsWidgetFieldDiff(
persistedGroups,
draftGroups,
);
if (fieldUpdates.length > 0) {
const viewFieldUpdateInputs = fieldUpdates.map(
({ viewFieldId, ...updates }) => ({
input: {
id: viewFieldId,
update: updates,
},
}),
);
await performViewFieldAPIUpdate(viewFieldUpdateInputs);
}
}
set(fieldsWidgetGroupsPersistedState, allDraftGroups);
await refreshAllCoreViews();
return { status: 'successful' as const };
},
[
fieldsWidgetGroupsDraftState,
fieldsWidgetGroupsPersistedState,
getViewIdForWidget,
performViewFieldGroupAPICreate,
performViewFieldGroupAPIDelete,
performViewFieldGroupAPIUpdate,
performViewFieldAPIUpdate,
refreshAllCoreViews,
],
);
return { saveFieldsWidgetGroups };
};
@@ -6,11 +6,13 @@ 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 { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
@@ -55,9 +57,17 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
result.response.data?.updatePageLayoutWithTabsAndWidgets;
if (isDefined(updatedPageLayout)) {
const pageLayoutToPersist: PageLayout =
const serverLayout: PageLayout =
transformPageLayout(updatedPageLayout);
const pageLayoutToPersist =
serverLayout.type === PageLayoutType.RECORD_PAGE
? reInjectDynamicRelationWidgetsFromDraft(
serverLayout,
pageLayoutDraft,
)
: serverLayout;
set(pageLayoutPersistedCallbackState, pageLayoutToPersist);
set(
pageLayoutCurrentLayoutsCallbackState,
@@ -1,80 +1,12 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type FieldsConfiguration,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
export const useTemporaryFieldsConfiguration = (
objectNameSingular: string,
): FieldsConfiguration => {
const { t } = useLingui();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const configuration = useMemo<FieldsConfiguration>(() => {
if (!isDefined(objectMetadataItem)) {
throw new Error('Object metadata item is not defined');
}
const fieldsToDisplay = objectMetadataItem.fields;
if (fieldsToDisplay.length === 0) {
throw new Error('No fields to display');
}
const generalFields: Array<{ fieldMetadataId: string; position: number }> =
[];
const otherFields: Array<{ fieldMetadataId: string; position: number }> =
[];
let generalPosition = 0;
let otherPosition = 0;
fieldsToDisplay.forEach((field) => {
if (field.isCustom === true) {
otherFields.push({
fieldMetadataId: field.id,
position: otherPosition++,
});
} else {
generalFields.push({
fieldMetadataId: field.id,
position: generalPosition++,
});
}
});
const sections = [];
if (generalFields.length > 0) {
sections.push({
id: `${objectNameSingular}-section-general`,
title: t`General`,
position: 0,
fields: generalFields,
});
}
if (otherFields.length > 0) {
sections.push({
id: `${objectNameSingular}-section-other`,
title: t`Other`,
position: 1,
fields: otherFields,
});
}
if (sections.length === 0) {
throw new Error('No sections to display');
}
return {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections,
};
}, [objectMetadataItem, objectNameSingular, t]);
return configuration;
export const useTemporaryFieldsConfiguration = (): FieldsConfiguration => {
return {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
};
};
@@ -0,0 +1,12 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const fieldsWidgetGroupsDraftComponentState = createComponentState<
Record<string, FieldsWidgetGroup[]>
>({
key: 'fieldsWidgetGroupsDraftComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,12 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const fieldsWidgetGroupsPersistedComponentState = createComponentState<
Record<string, FieldsWidgetGroup[]>
>({
key: 'fieldsWidgetGroupsPersistedComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,10 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const hasInitializedFieldsWidgetGroupsDraftComponentState =
createComponentState<Record<string, boolean>>({
key: 'hasInitializedFieldsWidgetGroupsDraftComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,19 @@
import { recordPageLayoutsState } from '@/page-layout/states/recordPageLayoutsState';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { selectorFamily } from 'recoil';
export const recordPageLayoutByObjectMetadataIdFamilySelector = selectorFamily<
PageLayout | undefined,
{ objectMetadataId: string }
>({
key: 'recordPageLayoutByObjectMetadataIdFamilySelector',
get:
({ objectMetadataId }) =>
({ get }) => {
const recordPageLayouts = get(recordPageLayoutsState);
return recordPageLayouts.find(
(pageLayout) => pageLayout.objectMetadataId === objectMetadataId,
);
},
});
@@ -1,5 +1,6 @@
import { type RulesLogic } from 'json-logic-js';
// Legacy types - kept for temporary configuration fallback
export type FieldsConfigurationFieldItem = {
fieldMetadataId: string;
position: number;
@@ -8,15 +9,9 @@ export type FieldsConfigurationFieldItem = {
isVisible?: boolean;
};
export type FieldsConfigurationSection = {
export type FieldsConfigurationGroup = {
id: string;
title: string;
position: number;
fields: FieldsConfigurationFieldItem[];
};
export type FieldsConfiguration = {
__typename: 'FieldsConfiguration';
configurationType: 'FIELDS';
sections: FieldsConfigurationSection[];
};
@@ -1,7 +1,7 @@
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { type Nullable } from 'twenty-shared/types';
import {
type FieldsConfiguration,
type PageLayoutWidget as PageLayoutWidgetGenerated,
type WidgetConfiguration,
} from '~/generated-metadata/graphql';
@@ -0,0 +1,337 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { computeFieldsWidgetFieldDiff } from '@/page-layout/utils/computeFieldsWidgetFieldDiff';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
const mockFieldMetadataItem = {
id: 'field-meta-1',
name: 'name',
label: 'Name',
type: 'TEXT',
} as FieldMetadataItem;
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
describe('computeFieldsWidgetFieldDiff', () => {
it('should return empty updates when both arrays are empty', () => {
const result = computeFieldsWidgetFieldDiff([], []);
expect(result).toEqual([]);
});
it('should return empty updates when fields are identical', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(groups, groups);
expect(result).toEqual([]);
});
it('should detect visibility changes', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: false,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([{ viewFieldId: 'vf1', isVisible: false }]);
});
it('should detect position changes', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 3,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([{ viewFieldId: 'vf1', position: 3 }]);
});
it('should detect group changes (field moved to different group)', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
makeGroup({ id: 'g2' }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1' }),
makeGroup({
id: 'g2',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([{ viewFieldId: 'vf1', viewFieldGroupId: 'g2' }]);
});
it('should detect multiple simultaneous changes on a field', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
makeGroup({ id: 'g2' }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1' }),
makeGroup({
id: 'g2',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 5,
isVisible: false,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([
{
viewFieldId: 'vf1',
isVisible: false,
position: 5,
viewFieldGroupId: 'g2',
},
]);
});
it('should skip fields without viewFieldId', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
},
],
}),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 5,
isVisible: false,
globalIndex: 0,
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([]);
});
it('should skip fields not found in persisted state', () => {
const persisted: FieldsWidgetGroup[] = [makeGroup({ id: 'g1' })];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf-new',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toEqual([]);
});
it('should handle multiple fields across multiple groups', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
{
fieldMetadataItem: mockFieldMetadataItem,
position: 1,
isVisible: true,
globalIndex: 1,
viewFieldId: 'vf2',
},
],
}),
makeGroup({
id: 'g2',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: false,
globalIndex: 2,
viewFieldId: 'vf3',
},
],
}),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
{
fieldMetadataItem: mockFieldMetadataItem,
position: 1,
isVisible: false,
globalIndex: 1,
viewFieldId: 'vf2',
},
],
}),
makeGroup({
id: 'g2',
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 2,
viewFieldId: 'vf3',
},
],
}),
];
const result = computeFieldsWidgetFieldDiff(persisted, draft);
expect(result).toHaveLength(2);
expect(result).toContainEqual({ viewFieldId: 'vf2', isVisible: false });
expect(result).toContainEqual({ viewFieldId: 'vf3', isVisible: true });
});
});
@@ -0,0 +1,191 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { computeFieldsWidgetGroupDiff } from '@/page-layout/utils/computeFieldsWidgetGroupDiff';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
const mockFieldMetadataItem = {
id: 'field-meta-1',
name: 'name',
label: 'Name',
type: 'TEXT',
} as FieldMetadataItem;
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
describe('computeFieldsWidgetGroupDiff', () => {
it('should return empty diffs when both arrays are empty', () => {
const result = computeFieldsWidgetGroupDiff([], []);
expect(result).toEqual({
createdGroups: [],
deletedGroups: [],
updatedGroups: [],
});
});
it('should return empty diffs when groups are identical', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
makeGroup({ id: 'g2', name: 'Group 2', position: 1 }),
];
const result = computeFieldsWidgetGroupDiff(groups, groups);
expect(result).toEqual({
createdGroups: [],
deletedGroups: [],
updatedGroups: [],
});
});
it('should detect created groups', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
makeGroup({ id: 'g2', name: 'New Group', position: 1 }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.createdGroups).toHaveLength(1);
expect(result.createdGroups[0].id).toBe('g2');
expect(result.deletedGroups).toHaveLength(0);
expect(result.updatedGroups).toHaveLength(0);
});
it('should detect deleted groups', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
makeGroup({ id: 'g2', name: 'Group 2', position: 1 }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.createdGroups).toHaveLength(0);
expect(result.deletedGroups).toHaveLength(1);
expect(result.deletedGroups[0].id).toBe('g2');
expect(result.updatedGroups).toHaveLength(0);
});
it('should detect updated groups when name changes', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Old Name', position: 0 }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'New Name', position: 0 }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.updatedGroups).toHaveLength(1);
expect(result.updatedGroups[0].name).toBe('New Name');
});
it('should detect updated groups when position changes', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 5 }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.updatedGroups).toHaveLength(1);
expect(result.updatedGroups[0].position).toBe(5);
});
it('should detect updated groups when visibility changes', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', isVisible: true }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', isVisible: false }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.updatedGroups).toHaveLength(1);
expect(result.updatedGroups[0].isVisible).toBe(false);
});
it('should handle simultaneous creates, deletes, and updates', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
makeGroup({ id: 'g2', name: 'To Delete', position: 1 }),
makeGroup({ id: 'g3', name: 'To Update', position: 2 }),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({ id: 'g1', name: 'Group 1', position: 0 }),
makeGroup({ id: 'g3', name: 'Updated Name', position: 2 }),
makeGroup({ id: 'g4', name: 'New Group', position: 3 }),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.createdGroups).toHaveLength(1);
expect(result.createdGroups[0].id).toBe('g4');
expect(result.deletedGroups).toHaveLength(1);
expect(result.deletedGroups[0].id).toBe('g2');
expect(result.updatedGroups).toHaveLength(1);
expect(result.updatedGroups[0].id).toBe('g3');
});
it('should not mark group as updated when fields differ but group props are same', () => {
const persisted: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
name: 'Group 1',
position: 0,
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const draft: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
name: 'Group 1',
position: 0,
fields: [
{
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: false,
globalIndex: 0,
viewFieldId: 'vf1',
},
],
}),
];
const result = computeFieldsWidgetGroupDiff(persisted, draft);
expect(result.updatedGroups).toHaveLength(0);
});
});
@@ -0,0 +1,130 @@
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 { PageLayoutType, 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: null,
objectMetadataId: null,
...overrides,
}) as PageLayoutWidget;
const makeDraft = (tabs: DraftPageLayout['tabs']): DraftPageLayout =>
({
id: 'layout-1',
name: 'Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'obj-1',
tabs,
}) as DraftPageLayout;
const makeTab = (
id: string,
widgets: PageLayoutWidget[],
): DraftPageLayout['tabs'][number] =>
({
id,
title: 'Tab',
position: 0,
pageLayoutId: 'layout-1',
applicationId: 'app-1',
widgets,
}) as DraftPageLayout['tabs'][number];
describe('convertPageLayoutDraftToUpdateInput', () => {
it('should convert a draft layout to update input', () => {
const widget = makeWidget({ id: 'w1' });
const draft = makeDraft([makeTab('tab-1', [widget])]);
const result = convertPageLayoutDraftToUpdateInput(draft);
expect(result.name).toBe('Layout');
expect(result.type).toBe(PageLayoutType.RECORD_PAGE);
expect(result.objectMetadataId).toBe('obj-1');
expect(result.tabs).toHaveLength(1);
expect(result.tabs[0].widgets).toHaveLength(1);
expect(result.tabs[0].widgets[0].id).toBe('w1');
});
it('should filter out dynamic relation widgets', () => {
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(1);
expect(result.tabs[0].widgets[0].id).toBe('w1');
});
it('should map gridPosition correctly', () => {
const widget = makeWidget({
id: 'w1',
gridPosition: { row: 2, column: 3, rowSpan: 4, columnSpan: 5 },
});
const draft = makeDraft([makeTab('tab-1', [widget])]);
const result = convertPageLayoutDraftToUpdateInput(draft);
expect(result.tabs[0].widgets[0].gridPosition).toEqual({
row: 2,
column: 3,
rowSpan: 4,
columnSpan: 5,
});
});
it('should handle objectMetadataId as null when not provided', () => {
const draft = makeDraft([makeTab('tab-1', [])]);
draft.objectMetadataId = undefined;
const result = convertPageLayoutDraftToUpdateInput(draft);
expect(result.objectMetadataId).toBeNull();
});
it('should handle multiple tabs', () => {
const draft = makeDraft([
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
makeTab('tab-2', [makeWidget({ id: 'w2' })]),
]);
const result = convertPageLayoutDraftToUpdateInput(draft);
expect(result.tabs).toHaveLength(2);
expect(result.tabs[0].id).toBe('tab-1');
expect(result.tabs[1].id).toBe('tab-2');
});
it('should keep all non-dynamic widgets when 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);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[0].widgets.map((w) => w.id)).toEqual(['w1', 'w2']);
});
});
@@ -1,6 +1,9 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { WidgetType } from '~/generated-metadata/graphql';
import { filterVisibleWidgets } from '@/page-layout/utils/filterVisibleWidgets';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('filterVisibleWidgets', () => {
const createMockWidget = (
@@ -22,8 +25,8 @@ describe('filterVisibleWidgets', () => {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
@@ -0,0 +1,85 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultOpportunityRecordPageLayoutId';
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
import { DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultTaskRecordPageLayoutId';
import { DEFAULT_WORKFLOW_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowPageLayoutId';
import { DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowRunPageLayoutId';
import { DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowVersionPageLayoutId';
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
describe('getDefaultRecordPageLayoutId', () => {
it('should return Company layout id for Company object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Company,
}),
).toBe(DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID);
});
it('should return Person layout id for Person object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Person,
}),
).toBe(DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID);
});
it('should return Opportunity layout id for Opportunity object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Opportunity,
}),
).toBe(DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID);
});
it('should return Note layout id for Note object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Note,
}),
).toBe(DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID);
});
it('should return Task layout id for Task object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Task,
}),
).toBe(DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID);
});
it('should return Workflow layout id for Workflow object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
}),
).toBe(DEFAULT_WORKFLOW_PAGE_LAYOUT_ID);
});
it('should return WorkflowVersion layout id for WorkflowVersion object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.WorkflowVersion,
}),
).toBe(DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID);
});
it('should return WorkflowRun layout id for WorkflowRun object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: CoreObjectNameSingular.WorkflowRun,
}),
).toBe(DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID);
});
it('should return default record page layout id for unknown object', () => {
expect(
getDefaultRecordPageLayoutId({
targetObjectNameSingular: 'customObject',
}),
).toBe(DEFAULT_RECORD_PAGE_LAYOUT_ID);
});
});
@@ -1,6 +1,9 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { WidgetType } from '~/generated-metadata/graphql';
import { getTabsWithVisibleWidgets } from '@/page-layout/utils/getTabsWithVisibleWidgets';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('getTabsWithVisibleWidgets', () => {
const createMockWidget = (
@@ -22,8 +25,8 @@ describe('getTabsWithVisibleWidgets', () => {
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: 'FIELDS',
sections: [],
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
@@ -0,0 +1,43 @@
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);
});
});
@@ -0,0 +1,175 @@
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 makeServerLayout = (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 server layout unchanged when draft has no dynamic widgets', () => {
const serverLayout = makeServerLayout([
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [makeWidget({ id: 'w1' })]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft);
expect(result).toBe(serverLayout);
});
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 serverLayout = makeServerLayout([
makeTab('tab-1', [fieldsWidget, timelineWidget]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget, dynamicWidget, timelineWidget]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, 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 serverLayout = makeServerLayout([makeTab('tab-1', [timelineWidget])]);
const draft = makeDraftLayout([
makeTab('tab-1', [timelineWidget, dynamicWidget]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, 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 serverLayout = makeServerLayout([
makeTab('tab-1', [fieldsWidget1]),
makeTab('tab-2', [fieldsWidget2]),
]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget1, dynamicWidget]),
makeTab('tab-2', [fieldsWidget2]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, 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 serverLayout = makeServerLayout([makeTab('tab-1', [fieldsWidget])]);
const draft = makeDraftLayout([
makeTab('tab-1', [fieldsWidget, dynamicWidget1, dynamicWidget2]),
]);
const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, 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`,
);
});
});
@@ -0,0 +1,36 @@
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
describe('sortTabsByPosition', () => {
it('should sort tabs by position ascending', () => {
const tabs = [
{ id: 'c', position: 3 },
{ id: 'a', position: 1 },
{ id: 'b', position: 2 },
];
const result = sortTabsByPosition(tabs);
expect(result.map((t) => t.id)).toEqual(['a', 'b', 'c']);
});
it('should return empty array for empty input', () => {
expect(sortTabsByPosition([])).toEqual([]);
});
it('should not mutate the original array', () => {
const tabs = [
{ id: 'b', position: 2 },
{ id: 'a', position: 1 },
];
sortTabsByPosition(tabs);
expect(tabs[0].id).toBe('b');
});
it('should handle single element', () => {
const tabs = [{ id: 'a', position: 0 }];
expect(sortTabsByPosition(tabs)).toEqual([{ id: 'a', position: 0 }]);
});
});
@@ -0,0 +1,111 @@
import { transformPageLayout } from '@/page-layout/utils/transformPageLayout';
import {
type PageLayout,
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
const makePageLayout = (overrides: Partial<PageLayout> = {}): PageLayout =>
({
__typename: 'PageLayout',
id: 'layout-1',
name: 'Test Layout',
type: PageLayoutType.RECORD_PAGE,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
tabs: [],
...overrides,
}) as PageLayout;
describe('transformPageLayout', () => {
it('should default tabs to empty array when null', () => {
const layout = makePageLayout({ tabs: null as any });
const result = transformPageLayout(layout);
expect(result.tabs).toEqual([]);
});
it('should sort tabs by position', () => {
const layout = makePageLayout({
tabs: [
{
id: 'tab-2',
position: 2,
title: 'Second',
pageLayoutId: 'layout-1',
applicationId: 'app-1',
layoutMode: PageLayoutTabLayoutMode.GRID,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
{
id: 'tab-1',
position: 1,
title: 'First',
pageLayoutId: 'layout-1',
applicationId: 'app-1',
layoutMode: PageLayoutTabLayoutMode.GRID,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
] as any,
});
const result = transformPageLayout(layout);
expect(result.tabs[0].id).toBe('tab-1');
expect(result.tabs[1].id).toBe('tab-2');
});
it('should default widgets to empty array when null', () => {
const layout = makePageLayout({
tabs: [
{
id: 'tab-1',
position: 0,
title: 'Tab',
pageLayoutId: 'layout-1',
applicationId: 'app-1',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
] as any,
});
const result = transformPageLayout(layout);
expect(result.tabs[0].widgets).toEqual([]);
});
it('should preserve existing widgets', () => {
const widget = {
id: 'w1',
title: 'Widget',
type: 'FIELDS',
};
const layout = makePageLayout({
tabs: [
{
id: 'tab-1',
position: 0,
title: 'Tab',
pageLayoutId: 'layout-1',
applicationId: 'app-1',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [widget],
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
},
] as any,
});
const result = transformPageLayout(layout);
expect(result.tabs[0].widgets).toHaveLength(1);
expect(result.tabs[0].widgets[0].id).toBe('w1');
});
});
@@ -0,0 +1,65 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { isDefined } from 'twenty-shared/utils';
export const computeFieldsWidgetFieldDiff = (
persistedGroups: FieldsWidgetGroup[],
draftGroups: FieldsWidgetGroup[],
) => {
const fieldUpdates: Array<{
viewFieldId: string;
isVisible?: boolean;
position?: number;
viewFieldGroupId?: string;
}> = [];
const persistedFieldMap = new Map<
string,
{
isVisible: boolean;
position: number;
groupId: string;
}
>();
for (const group of persistedGroups) {
for (const field of group.fields) {
if (isDefined(field.viewFieldId)) {
persistedFieldMap.set(field.viewFieldId, {
isVisible: field.isVisible,
position: field.position,
groupId: group.id,
});
}
}
}
for (const draftGroup of draftGroups) {
for (const draftField of draftGroup.fields) {
if (!isDefined(draftField.viewFieldId)) {
continue;
}
const persistedField = persistedFieldMap.get(draftField.viewFieldId);
if (!isDefined(persistedField)) {
continue;
}
const hasVisibilityChange =
persistedField.isVisible !== draftField.isVisible;
const hasPositionChange = persistedField.position !== draftField.position;
const hasGroupChange = persistedField.groupId !== draftGroup.id;
if (hasVisibilityChange || hasPositionChange || hasGroupChange) {
fieldUpdates.push({
viewFieldId: draftField.viewFieldId,
...(hasVisibilityChange ? { isVisible: draftField.isVisible } : {}),
...(hasPositionChange ? { position: draftField.position } : {}),
...(hasGroupChange ? { viewFieldGroupId: draftGroup.id } : {}),
});
}
}
}
return fieldUpdates;
};
@@ -0,0 +1,34 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { isDefined } from 'twenty-shared/utils';
export const computeFieldsWidgetGroupDiff = (
persistedGroups: FieldsWidgetGroup[],
draftGroups: FieldsWidgetGroup[],
) => {
const persistedGroupIds = new Set(persistedGroups.map((g) => g.id));
const draftGroupIds = new Set(draftGroups.map((g) => g.id));
const createdGroups = draftGroups.filter((g) => !persistedGroupIds.has(g.id));
const deletedGroups = persistedGroups.filter((g) => !draftGroupIds.has(g.id));
const updatedGroups = draftGroups.filter((draftGroup) => {
if (!persistedGroupIds.has(draftGroup.id)) {
return false;
}
const persistedGroup = persistedGroups.find((g) => g.id === draftGroup.id);
if (!isDefined(persistedGroup)) {
return false;
}
return (
persistedGroup.name !== draftGroup.name ||
persistedGroup.position !== draftGroup.position ||
persistedGroup.isVisible !== draftGroup.isVisible
);
});
return { createdGroups, deletedGroups, updatedGroups };
};
@@ -1,4 +1,5 @@
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWidget';
import { type UpdatePageLayoutWithTabsInput } from '~/generated-metadata/graphql';
export const convertPageLayoutDraftToUpdateInput = (
@@ -12,20 +13,22 @@ export const convertPageLayoutDraftToUpdateInput = (
id: tab.id,
title: tab.title,
position: tab.position,
widgets: tab.widgets.map((widget) => ({
id: widget.id,
pageLayoutTabId: widget.pageLayoutTabId,
title: widget.title,
type: widget.type,
objectMetadataId: widget.objectMetadataId ?? null,
gridPosition: {
row: widget.gridPosition.row,
column: widget.gridPosition.column,
rowSpan: widget.gridPosition.rowSpan,
columnSpan: widget.gridPosition.columnSpan,
},
configuration: widget.configuration ?? null,
})),
widgets: tab.widgets
.filter((widget) => !isDynamicRelationWidget(widget))
.map((widget) => ({
id: widget.id,
pageLayoutTabId: widget.pageLayoutTabId,
title: widget.title,
type: widget.type,
objectMetadataId: widget.objectMetadataId ?? null,
gridPosition: {
row: widget.gridPosition.row,
column: widget.gridPosition.column,
rowSpan: widget.gridPosition.rowSpan,
columnSpan: widget.gridPosition.columnSpan,
},
configuration: widget.configuration ?? null,
})),
})),
};
};
@@ -0,0 +1,34 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultNoteRecordPageLayoutId';
import { DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultOpportunityRecordPageLayoutId';
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
import { DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultTaskRecordPageLayoutId';
import { DEFAULT_WORKFLOW_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowPageLayoutId';
import { DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowRunPageLayoutId';
import { DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultWorkflowVersionPageLayoutId';
const OBJECT_NAME_TO_DEFAULT_LAYOUT_ID: Record<string, string> = {
[CoreObjectNameSingular.Company]: DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.Person]: DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.Opportunity]:
DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.Note]: DEFAULT_NOTE_RECORD_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.Task]: DEFAULT_TASK_RECORD_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.Workflow]: DEFAULT_WORKFLOW_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.WorkflowVersion]:
DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT_ID,
[CoreObjectNameSingular.WorkflowRun]: DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT_ID,
};
export const getDefaultRecordPageLayoutId = ({
targetObjectNameSingular,
}: {
targetObjectNameSingular: string;
}): string => {
return (
OBJECT_NAME_TO_DEFAULT_LAYOUT_ID[targetObjectNameSingular] ??
DEFAULT_RECORD_PAGE_LAYOUT_ID
);
};
@@ -1,6 +1,7 @@
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 {
WidgetConfigurationType,
@@ -12,7 +13,7 @@ const getRelationFieldWidgetToInsert = (
tabId: string,
): PageLayoutWidget => ({
__typename: 'PageLayoutWidget' as const,
id: `dynamic-relation-widget-${field.id}-${field.label}`,
id: `${DYNAMIC_RELATION_WIDGET_ID_PREFIX}${field.id}-${field.label}`,
pageLayoutTabId: tabId,
title: field.label,
type: WidgetType.FIELD,
@@ -0,0 +1,7 @@
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);
};
@@ -0,0 +1,54 @@
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 = (
serverLayout: 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 serverLayout;
}
return {
...serverLayout,
tabs: serverLayout.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],
};
}),
};
};
@@ -10,11 +10,8 @@ export const transformPageLayout = (
tabs: (pageLayout.tabs ?? [])
.toSorted((a, b) => a.position - b.position)
.map((tab): PageLayoutTab => {
// TODO: remove this once the frontend consumes the new type
const { layoutMode: _layoutMode, ...tabWithoutLayoutMode } = tab;
return {
...tabWithoutLayoutMode,
...tab,
widgets: tab.widgets ?? [],
};
}),
@@ -0,0 +1,61 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('assertFieldWidgetOrThrow', () => {
it('should not throw for a valid field widget', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: 'fm-1',
layout: 'FIELD',
},
} as PageLayoutWidget;
expect(() => assertFieldWidgetOrThrow(widget)).not.toThrow();
});
it('should throw when configuration is undefined', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: undefined,
} as unknown as PageLayoutWidget;
expect(() => assertFieldWidgetOrThrow(widget)).toThrow(
'Widget configuration is required',
);
});
it('should throw when configuration typename does not match', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: {
__typename: 'WidgetConfiguration',
},
} as unknown as PageLayoutWidget;
expect(() => assertFieldWidgetOrThrow(widget)).toThrow(
'Expected FieldConfiguration but got WidgetConfiguration',
);
});
it('should throw when configuration typename is empty string', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: {
__typename: '',
},
} as unknown as PageLayoutWidget;
expect(() => assertFieldWidgetOrThrow(widget)).toThrow();
});
});
@@ -0,0 +1,25 @@
import { generateFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/generateFieldWidgetInstanceId';
describe('generateFieldWidgetInstanceId', () => {
it('should generate an instance id without right drawer suffix', () => {
const result = generateFieldWidgetInstanceId({
widgetId: 'widget-1',
recordId: 'record-1',
fieldName: 'name',
isInRightDrawer: false,
});
expect(result).toBe('widget-1-field-widget-record-1-name');
});
it('should append right drawer suffix when isInRightDrawer is true', () => {
const result = generateFieldWidgetInstanceId({
widgetId: 'widget-1',
recordId: 'record-1',
fieldName: 'name',
isInRightDrawer: true,
});
expect(result).toBe('widget-1-field-widget-record-1-name-right-drawer');
});
});
@@ -0,0 +1,60 @@
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
describe('isFieldWidget', () => {
it('should return true for a FIELD widget with FieldConfiguration', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: 'fm-1',
layout: 'FIELD',
},
} as PageLayoutWidget;
expect(isFieldWidget(widget)).toBe(true);
});
it('should return false for a non-FIELD widget type', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELDS,
configuration: {
__typename: 'FieldConfiguration',
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: 'fm-1',
layout: 'FIELD',
},
} as PageLayoutWidget;
expect(isFieldWidget(widget)).toBe(false);
});
it('should return false when configuration is null', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: null,
} as unknown as PageLayoutWidget;
expect(isFieldWidget(widget)).toBe(false);
});
it('should return false when configuration typename does not match', () => {
const widget = {
id: 'w1',
type: WidgetType.FIELD,
configuration: {
__typename: 'WidgetConfiguration',
},
} as unknown as PageLayoutWidget;
expect(isFieldWidget(widget)).toBe(false);
});
});
@@ -7,43 +7,56 @@ import {
} from '@hello-pangea/dnd';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import {
type FieldsConfiguration,
type FieldsConfigurationFieldItem,
type FieldsConfigurationSection,
} from '@/page-layout/types/FieldsConfiguration';
import { FieldsConfigurationSectionEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationSectionEditor';
import { FieldsConfigurationGroupEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor';
import { useCreateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup';
import { useFieldsWidgetGroupsDraft } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft';
import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft';
import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups';
import { useToggleFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleFieldVisibilityInDraft';
import { useLingui } from '@lingui/react/macro';
const StyledSectionsDroppable = styled.div`
const StyledGroupsDroppable = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
type FieldsConfigurationEditorProps = {
configuration: FieldsConfiguration;
onChange: (configuration: FieldsConfiguration) => void;
pageLayoutId: string;
widgetId: string;
};
export const FieldsConfigurationEditor = ({
configuration,
onChange,
pageLayoutId,
widgetId,
}: FieldsConfigurationEditorProps) => {
const { t } = useLingui();
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const handleSectionChange = (
sectionId: string,
updatedSection: FieldsConfigurationSection,
) => {
const updatedSections = configuration.sections.map((section) =>
section.id === sectionId ? updatedSection : section,
);
const { draftGroups } = useFieldsWidgetGroupsDraft({
pageLayoutId,
widgetId,
});
onChange({
...configuration,
sections: updatedSections,
});
};
const { createGroup } = useCreateFieldsWidgetEditorGroup({
pageLayoutId,
widgetId,
});
const { reorderGroups } = useReorderFieldsWidgetEditorGroups({
pageLayoutId,
widgetId,
});
const { moveField } = useMoveFieldInDraft({
pageLayoutId,
widgetId,
});
const { toggleFieldVisibility } = useToggleFieldVisibilityInDraft({
pageLayoutId,
widgetId,
});
const handleDragEnd = (result: DropResult) => {
const { source, destination, type } = result;
@@ -59,8 +72,8 @@ export const FieldsConfigurationEditor = ({
return;
}
if (type === 'SECTION') {
handleSectionReorder(source.index, destination.index);
if (type === 'GROUP') {
handleGroupReorder(source.index, destination.index);
} else if (type === 'FIELD') {
handleFieldMove(
source.droppableId,
@@ -71,144 +84,81 @@ export const FieldsConfigurationEditor = ({
}
};
const handleSectionReorder = (
const handleGroupReorder = (
sourceIndex: number,
destinationIndex: number,
) => {
const sortedSections = [...configuration.sections].sort(
const sortedGroups = [...draftGroups].sort(
(a, b) => a.position - b.position,
);
const [movedSection] = sortedSections.splice(sourceIndex, 1);
sortedSections.splice(destinationIndex, 0, movedSection);
const reorderedGroupIds = sortedGroups.map((g) => g.id);
const [movedGroupId] = reorderedGroupIds.splice(sourceIndex, 1);
reorderedGroupIds.splice(destinationIndex, 0, movedGroupId);
const updatedSections = sortedSections.map((section, index) => ({
...section,
position: index,
}));
onChange({
...configuration,
sections: updatedSections,
});
reorderGroups(reorderedGroupIds);
};
const handleFieldMove = (
sourceSectionId: string,
destinationSectionId: string,
sourceGroupId: string,
destinationGroupId: string,
sourceIndex: number,
destinationIndex: number,
) => {
const sourceSection = configuration.sections.find(
(s) => `section-${s.id}` === sourceSectionId,
const cleanSourceGroupId = sourceGroupId.replace('group-', '');
const cleanDestinationGroupId = destinationGroupId.replace('group-', '');
moveField(
cleanSourceGroupId,
cleanDestinationGroupId,
sourceIndex,
destinationIndex,
);
const destinationSection = configuration.sections.find(
(s) => `section-${s.id}` === destinationSectionId,
);
if (!sourceSection || !destinationSection) {
return;
}
const sourceSortedFields = [...sourceSection.fields].sort(
(a, b) => a.position - b.position,
);
if (sourceSectionId === destinationSectionId) {
// Reorder within the same section
const [movedField] = sourceSortedFields.splice(sourceIndex, 1);
sourceSortedFields.splice(destinationIndex, 0, movedField);
const updatedFields = sourceSortedFields.map((field, index) => ({
...field,
position: index,
}));
handleSectionChange(sourceSection.id, {
...sourceSection,
fields: updatedFields,
});
} else {
// Move field between sections
const [movedField] = sourceSortedFields.splice(sourceIndex, 1);
const destinationSortedFields = [...destinationSection.fields].sort(
(a, b) => a.position - b.position,
);
destinationSortedFields.splice(destinationIndex, 0, movedField);
const updatedSourceFields = sourceSortedFields.map(
(field, index) =>
({
...field,
position: index,
}) satisfies FieldsConfigurationFieldItem,
);
const updatedDestinationFields = destinationSortedFields.map(
(field, index) =>
({
...field,
position: index,
}) satisfies FieldsConfigurationFieldItem,
);
const updatedSections = configuration.sections.map((section) => {
if (section.id === sourceSection.id) {
return { ...section, fields: updatedSourceFields };
}
if (section.id === destinationSection.id) {
return { ...section, fields: updatedDestinationFields };
}
return section;
});
onChange({
...configuration,
sections: updatedSections,
});
}
};
const sortedSections = [...configuration.sections].sort(
(a, b) => a.position - b.position,
);
const handleAddGroup = () => {
const newGroupName = t`New Group`;
createGroup(newGroupName);
};
if (sortedSections.length === 0) {
const sortedGroups = [...draftGroups].sort((a, b) => a.position - b.position);
if (sortedGroups.length === 0) {
return null;
}
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="sections" type="SECTION">
<Droppable droppableId="groups" type="GROUP">
{(provided) => (
<StyledSectionsDroppable
<StyledGroupsDroppable
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.droppableProps}
>
{sortedSections.map((section, index) => (
{sortedGroups.map((group, index) => (
<Draggable
key={section.id}
draggableId={`section-draggable-${section.id}`}
key={group.id}
draggableId={`group-draggable-${group.id}`}
index={index}
>
{(draggableProvided, snapshot) => (
<FieldsConfigurationSectionEditor
section={section}
<FieldsConfigurationGroupEditor
group={group}
index={index}
objectMetadataItem={objectMetadataItem}
onSectionChange={(updatedSection) =>
handleSectionChange(section.id, updatedSection)
}
draggableProvided={draggableProvided}
isDragging={snapshot.isDragging}
onAddGroup={handleAddGroup}
onToggleFieldVisibility={(fieldMetadataId) =>
toggleFieldVisibility(group.id, fieldMetadataId)
}
/>
)}
</Draggable>
))}
{provided.placeholder}
</StyledSectionsDroppable>
</StyledGroupsDroppable>
)}
</Droppable>
</DragDropContext>
@@ -15,7 +15,7 @@ export const FieldsConfigurationFieldEditor = ({
onToggleVisibility,
}: FieldsConfigurationFieldEditorProps) => {
const { getIcon } = useIcons();
const isVisible = field.conditionalDisplay !== false;
const isVisible = field.isVisible ?? true;
const FieldIcon = getIcon(fieldMetadata.icon);
return (
@@ -5,11 +5,8 @@ import { useLingui } from '@lingui/react/macro';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
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 { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { IconDotsVertical, IconNewSection } from 'twenty-ui/display';
import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation';
@@ -18,7 +15,7 @@ const StyledFieldsDroppable = styled.div`
flex-direction: column;
`;
const StyledSectionContainer = styled.div<{ isDragging: boolean }>`
const StyledGroupContainer = styled.div<{ isDragging: boolean }>`
background: ${({ isDragging, theme }) =>
isDragging ? theme.background.primary : 'transparent'};
border: 1px solid
@@ -30,61 +27,31 @@ const StyledSectionContainer = styled.div<{ isDragging: boolean }>`
width: 100%;
`;
type FieldsConfigurationSectionEditorProps = {
section: FieldsConfigurationSection;
type FieldsConfigurationGroupEditorProps = {
group: FieldsWidgetGroup;
index: number;
objectMetadataItem: ObjectMetadataItem;
onSectionChange: (section: FieldsConfigurationSection) => void;
draggableProvided: DraggableProvided;
isDragging: boolean;
onAddGroup?: () => void;
onToggleFieldVisibility: (fieldMetadataId: string) => void;
};
export const FieldsConfigurationSectionEditor = ({
section,
objectMetadataItem,
onSectionChange,
export const FieldsConfigurationGroupEditor = ({
group,
draggableProvided,
isDragging,
}: FieldsConfigurationSectionEditorProps) => {
onAddGroup,
onToggleFieldVisibility,
}: FieldsConfigurationGroupEditorProps) => {
const { t } = useLingui();
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 updatedField: FieldsConfigurationFieldItem = {
...field,
conditionalDisplay: false,
};
handleFieldChange(fieldMetadataId, updatedField);
};
const sortedFields = [...section.fields].sort(
const sortedFields = [...group.fields].sort(
(a, b) => a.position - b.position,
);
return (
<StyledSectionContainer
<StyledGroupContainer
ref={draggableProvided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided.draggableProps}
@@ -93,7 +60,7 @@ export const FieldsConfigurationSectionEditor = ({
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<div {...draggableProvided.dragHandleProps}>
<MenuItemDraggable
text={section.title}
text={group.name}
gripMode="always"
isIconDisplayedOnHoverOnly={false}
withIconContainer
@@ -102,14 +69,14 @@ export const FieldsConfigurationSectionEditor = ({
Icon: IconDotsVertical,
onClick: (e) => {
e.stopPropagation();
// TODO: Add section menu
// TODO: Add group menu
},
},
]}
/>
</div>
<Droppable droppableId={`section-${section.id}`} type="FIELD">
<Droppable droppableId={`group-${group.id}`} type="FIELD">
{(droppableProvided) => (
<StyledFieldsDroppable
ref={droppableProvided.innerRef}
@@ -117,27 +84,23 @@ export const FieldsConfigurationSectionEditor = ({
{...droppableProvided.droppableProps}
>
{sortedFields.map((field, fieldIndex) => {
const fieldMetadata = objectMetadataItem.fields.find(
(f) => f.id === field.fieldMetadataId,
);
if (!fieldMetadata) {
return null;
}
return (
<DraggableItem
key={field.fieldMetadataId}
draggableId={`field-${field.fieldMetadataId}`}
key={field.fieldMetadataItem.id}
draggableId={`field-${field.fieldMetadataItem.id}`}
index={fieldIndex}
isInsideScrollableContainer
itemComponent={
<FieldsConfigurationFieldEditor
field={field}
fieldMetadata={fieldMetadata}
onToggleVisibility={() =>
handleToggleFieldVisibility(field.fieldMetadataId)
}
field={{
fieldMetadataId: field.fieldMetadataItem.id,
position: field.position,
isVisible: field.isVisible,
}}
fieldMetadata={field.fieldMetadataItem}
onToggleVisibility={() => {
onToggleFieldVisibility(field.fieldMetadataItem.id);
}}
/>
}
/>
@@ -151,11 +114,9 @@ export const FieldsConfigurationSectionEditor = ({
<MenuItem
LeftIcon={IconNewSection}
withIconContainer
text={t`Add a Section`}
onClick={() => {
// TODO: Implement add section
}}
text={t`Add a Group`}
onClick={onAddGroup}
/>
</StyledSectionContainer>
</StyledGroupContainer>
);
};
@@ -21,8 +21,8 @@ import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFi
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { FieldsWidgetCellEditModePortal } from '@/page-layout/widgets/fields/components/FieldsWidgetCellEditModePortal';
import { FieldsWidgetCellHoveredPortal } from '@/page-layout/widgets/fields/components/FieldsWidgetCellHoveredPortal';
import { FieldsWidgetSectionContainer } from '@/page-layout/widgets/fields/components/FieldsWidgetSectionContainer';
import { useFieldsWidgetSectionsWithIndices } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithIndices';
import { FieldsWidgetGroupContainer } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupContainer';
import { useFieldsWidgetGroupsForDisplay } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
@@ -38,6 +38,7 @@ import {
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
box-sizing: border-box;
@@ -64,7 +65,7 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
const targetRecord = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
const instanceId = `${widget.id}-${targetRecord.id}${isInRightDrawer ? '-right-drawer' : ''}`;
const instanceId = `fields-${widget.id}-${targetRecord.id}${isInRightDrawer ? '-right-drawer' : ''}`;
const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({
objectRecordId: targetRecord.id,
@@ -91,11 +92,19 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
instanceId,
);
const { sectionsWithFieldIndices } = useFieldsWidgetSectionsWithIndices(
targetRecord.targetObjectNameSingular,
const fieldsConfiguration = widget.configuration as FieldsConfiguration;
const { groups } = useFieldsWidgetGroupsForDisplay({
widgetId: widget.id,
viewId: fieldsConfiguration.viewId ?? null,
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const flattenedFieldMetadataItems = groups.flatMap((group) =>
group.fields.map((field) => field.fieldMetadataItem),
);
const hasFieldsToDisplay = sectionsWithFieldIndices.length > 0;
const hasFieldsToDisplay = groups.length > 0;
if (!hasFieldsToDisplay) {
return (
@@ -128,118 +137,113 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
instanceId,
}}
>
{sectionsWithFieldIndices.map((section) => (
<FieldsWidgetSectionContainer
key={section.id}
title={section.title}
>
{groups.map((group) => (
<FieldsWidgetGroupContainer key={group.id} title={group.name}>
<StyledPropertyBox>
{isPrefetchLoading ? (
<PropertyBoxSkeletonLoader />
) : (
<>
{section.fields.map(
({ field: fieldMetadataItem, globalIndex }) => {
const isActivityTarget = isActivityTargetField(
fieldMetadataItem.name,
targetRecord.targetObjectNameSingular,
);
{group.fields.map(({ fieldMetadataItem, globalIndex }) => {
const isActivityTarget = isActivityTargetField(
fieldMetadataItem.name,
targetRecord.targetObjectNameSingular,
);
return (
<FieldContext.Provider
key={targetRecord.id + fieldMetadataItem.id}
value={{
recordId: targetRecord.id,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
return (
<FieldContext.Provider
key={targetRecord.id + fieldMetadataItem.id}
value={{
recordId: targetRecord.id,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId(
{
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
},
),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly:
fieldMetadataItem.isUIReadOnly ?? false,
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly:
fieldMetadataItem.isUIReadOnly ?? false,
},
}),
onMouseEnter: () =>
setRecordFieldListHoverPosition(globalIndex),
anchorId: `${getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
{isActivityTarget ? (
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId(
{
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
},
}),
onMouseEnter: () =>
setRecordFieldListHoverPosition(globalIndex),
anchorId: `${getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
isForbidden: isJunctionRelationForbidden({
fieldMetadataItem,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
}),
}}
>
{isActivityTarget ? (
<ActivityTargetsInlineCell
componentInstanceId={getRecordFieldInputInstanceId(
{
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
},
)}
activityObjectNameSingular={
targetRecord.targetObjectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={targetRecord.id}
showLabel={true}
maxWidth={200}
/>
) : (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<RecordInlineCell loading={recordLoading} />
</RecordFieldComponentInstanceContext.Provider>
)}
</FieldContext.Provider>
);
},
)}
)}
activityObjectNameSingular={
targetRecord.targetObjectNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task
}
activityRecordId={targetRecord.id}
showLabel={true}
maxWidth={200}
/>
) : (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<RecordInlineCell loading={recordLoading} />
</RecordFieldComponentInstanceContext.Provider>
)}
</FieldContext.Provider>
);
})}
</>
)}
</StyledPropertyBox>
</FieldsWidgetSectionContainer>
</FieldsWidgetGroupContainer>
))}
<FieldsWidgetCellHoveredPortal
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
flattenedFieldMetadataItems={flattenedFieldMetadataItems}
/>
<FieldsWidgetCellEditModePortal
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
flattenedFieldMetadataItems={flattenedFieldMetadataItems}
/>
</RecordFieldListComponentInstanceContext.Provider>
</StyledContainer>
@@ -1,3 +1,4 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldListInputContextProvider } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListInputContextProvider';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
@@ -5,7 +6,6 @@ import { recordFieldListCellEditModePositionComponentState } from '@/object-reco
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { useFieldsWidgetFlattenedFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFlattenedFields';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
@@ -13,11 +13,13 @@ import { isDefined } from 'twenty-shared/utils';
type FieldsWidgetCellEditModePortalProps = {
objectMetadataItem: ObjectMetadataItem;
recordId: string;
flattenedFieldMetadataItems: FieldMetadataItem[];
};
export const FieldsWidgetCellEditModePortal = ({
objectMetadataItem,
recordId,
flattenedFieldMetadataItems,
}: FieldsWidgetCellEditModePortalProps) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldListComponentInstanceContext,
@@ -27,10 +29,6 @@ export const FieldsWidgetCellEditModePortal = ({
recordFieldListCellEditModePositionComponentState,
);
const { flattenedFieldMetadataItems } = useFieldsWidgetFlattenedFields(
objectMetadataItem.nameSingular,
);
const editedFieldMetadataItem = isDefined(editModePosition)
? flattenedFieldMetadataItems.at(editModePosition)
: undefined;
@@ -1,10 +1,10 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldListCellHoveredPortalContent } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent';
import { RecordFieldListInputContextProvider } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListInputContextProvider';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { useFieldsWidgetFlattenedFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFlattenedFields';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
@@ -12,11 +12,13 @@ import { isDefined } from 'twenty-shared/utils';
type FieldsWidgetCellHoveredPortalProps = {
objectMetadataItem: ObjectMetadataItem;
recordId: string;
flattenedFieldMetadataItems: FieldMetadataItem[];
};
export const FieldsWidgetCellHoveredPortal = ({
objectMetadataItem,
recordId,
flattenedFieldMetadataItems,
}: FieldsWidgetCellHoveredPortalProps) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldListComponentInstanceContext,
@@ -26,10 +28,6 @@ export const FieldsWidgetCellHoveredPortal = ({
recordFieldListHoverPositionComponentState,
);
const { flattenedFieldMetadataItems } = useFieldsWidgetFlattenedFields(
objectMetadataItem.nameSingular,
);
const hoveredFieldMetadataItem = isDefined(hoverPosition)
? flattenedFieldMetadataItems.at(hoverPosition)
: undefined;
@@ -1,5 +1,5 @@
import { useTheme } from '@emotion/react';
import isPropValid from '@emotion/is-prop-valid';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconChevronDown } from 'twenty-ui/display';
@@ -28,24 +28,24 @@ const StyledChevronIcon = styled(IconChevronDown, {
`transform ${theme.animation.duration.normal}s ease`};
`;
type FieldsWidgetSectionContainerProps = {
type FieldsWidgetGroupContainerProps = {
children: React.ReactNode;
title: string;
};
export const FieldsWidgetSectionContainer = ({
export const FieldsWidgetGroupContainer = ({
children,
title,
}: FieldsWidgetSectionContainerProps) => {
}: FieldsWidgetGroupContainerProps) => {
const [isExpanded, setIsExpanded] = useState(true);
const theme = useTheme();
const handleToggleSection = () =>
const handleToggleGroup = () =>
setIsExpanded((previousIsExpanded) => !previousIsExpanded);
return (
<Section>
<StyledHeader onClick={handleToggleSection}>
<StyledHeader onClick={handleToggleGroup}>
<StyledTitleLabel>{title}</StyledTitleLabel>
<StyledChevronIcon
isExpanded={isExpanded}
@@ -0,0 +1,30 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useFieldsWidgetEditorGroupsData } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData';
import { useInitializeFieldsWidgetGroupsDraft } from '@/page-layout/widgets/fields/hooks/useInitializeFieldsWidgetGroupsDraft';
type FieldsWidgetGroupsDraftInitializationEffectProps = {
viewId: string | null;
pageLayoutId: string;
widgetId: string;
};
export const FieldsWidgetGroupsDraftInitializationEffect = ({
viewId,
pageLayoutId,
widgetId,
}: FieldsWidgetGroupsDraftInitializationEffectProps) => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { groups: serverGroups } = useFieldsWidgetEditorGroupsData({
viewId,
objectNameSingular: objectMetadataItem.nameSingular,
});
useInitializeFieldsWidgetGroupsDraft({
pageLayoutId,
widgetId,
serverGroups,
});
return null;
};
@@ -0,0 +1,60 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
type UseCreateFieldsWidgetEditorGroupParams = {
pageLayoutId: string;
widgetId: string;
};
export const useCreateFieldsWidgetEditorGroup = ({
pageLayoutId,
widgetId,
}: UseCreateFieldsWidgetEditorGroupParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const createGroup = useRecoilCallback(
({ set, snapshot }) =>
(name: string) => {
const allDraftGroups = snapshot
.getLoadable(fieldsWidgetGroupsDraftState)
.getValue();
const currentGroups = allDraftGroups[widgetId] ?? [];
const maxPosition = Math.max(
...currentGroups.map((g) => g.position),
-1,
);
const newId = v4();
set(fieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[widgetId]: [
...(prev[widgetId] ?? []),
{
id: newId,
name,
position: maxPosition + 1,
isVisible: true,
fields: [],
},
],
}));
return newId;
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const createGroupCallback = useCallback(
(name: string) => createGroup(name),
[createGroup],
);
return { createGroup: createGroupCallback };
};
@@ -0,0 +1,39 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
type UseDeleteFieldsWidgetEditorGroupParams = {
pageLayoutId: string;
widgetId: string;
};
export const useDeleteFieldsWidgetEditorGroup = ({
pageLayoutId,
widgetId,
}: UseDeleteFieldsWidgetEditorGroupParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const deleteGroupRecoilCallback = useRecoilCallback(
({ set }) =>
(groupId: string) => {
set(fieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[widgetId]: (prev[widgetId] ?? []).filter(
(group) => group.id !== groupId,
),
}));
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const deleteGroup = useCallback(
(groupId: string) => deleteGroupRecoilCallback(groupId),
[deleteGroupRecoilCallback],
);
return { deleteGroup };
};
@@ -0,0 +1,118 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useGetViewById } from '@/views/hooks/useGetViewById';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseFieldsWidgetEditorGroupsDataParams = {
viewId: string | null;
objectNameSingular: string;
};
export const useFieldsWidgetEditorGroupsData = ({
viewId,
objectNameSingular,
}: UseFieldsWidgetEditorGroupsDataParams) => {
const { t } = useLingui();
const { view } = useGetViewById(viewId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const groups = useMemo<FieldsWidgetGroup[]>(() => {
if (!isDefined(objectMetadataItem)) {
return [];
}
if (isDefined(view) && isDefined(view.viewFieldGroups)) {
const viewFieldGroups = view.viewFieldGroups;
const sortedGroups = [...viewFieldGroups].sort(
(a, b) => a.position - b.position,
);
let globalIndex = 0;
return sortedGroups.map((group) => {
const groupFields = [...(group.viewFields ?? [])].sort(
(a, b) => a.position - b.position,
);
const fields = groupFields
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
viewFieldId: viewField.id,
};
})
.filter(isDefined);
return {
id: group.id,
name: group.name,
position: group.position,
isVisible: group.isVisible,
fields,
};
});
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
const fields = [...view.viewFields]
.sort((a, b) => a.position - b.position)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
viewFieldId: viewField.id,
};
})
.filter(isDefined);
if (fields.length > 0) {
return [
{
id: `${view.id}-group-general`,
name: t`General`,
position: 0,
isVisible: true,
fields,
},
];
}
}
return [];
}, [objectMetadataItem, t, view]);
return {
groups,
isFromView:
isDefined(view) &&
(isDefined(view.viewFieldGroups) || view.viewFields.length > 0),
};
};
@@ -1,12 +0,0 @@
import { useFieldsWidgetSectionsWithFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithFields';
export const useFieldsWidgetFlattenedFields = (objectNameSingular: string) => {
const { sectionsWithFields } =
useFieldsWidgetSectionsWithFields(objectNameSingular);
const flattenedFieldMetadataItems = sectionsWithFields.flatMap(
(section) => section.fields,
);
return { flattenedFieldMetadataItems };
};
@@ -0,0 +1,201 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useFieldsWidgetFieldMetadataItems } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFieldMetadataItems';
import {
type FieldsWidgetGroup,
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useGetViewById } from '@/views/hooks/useGetViewById';
import { useLingui } from '@lingui/react/macro';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseFieldsWidgetGroupsParams = {
viewId: string | null;
objectNameSingular: string;
};
export const useFieldsWidgetGroups = ({
viewId,
objectNameSingular,
}: UseFieldsWidgetGroupsParams) => {
const { t } = useLingui();
const { view } = useGetViewById(viewId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { inlineFieldMetadataItems } = useFieldsWidgetFieldMetadataItems({
objectNameSingular,
});
const groups = useMemo<FieldsWidgetGroup[]>(() => {
if (!isDefined(objectMetadataItem)) {
return [];
}
// Views are seeded for object
if (isDefined(view) && isDefined(view.viewFieldGroups)) {
const sortedGroups = view.viewFieldGroups.toSorted(
(a, b) => a.position - b.position,
);
let globalIndex = 0;
return sortedGroups
.filter((group) => group.isVisible)
.map((group) => {
const groupFields = [...(group.viewFields ?? [])].sort(
(a, b) => a.position - b.position,
);
const fields: FieldsWidgetGroupField[] = groupFields
.filter((field) => field.isVisible)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
const field: FieldsWidgetGroupField = {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
};
return field;
})
.filter(isDefined);
return {
id: group.id,
name: group.name,
position: group.position,
isVisible: group.isVisible,
fields,
};
})
.filter((group) => group.fields.length > 0);
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
const fields: FieldsWidgetGroupField[] = [...view.viewFields]
.sort((a, b) => a.position - b.position)
.filter((viewField) => viewField.isVisible)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: globalIndex++,
};
})
.filter(isDefined);
if (fields.length > 0) {
return [
{
id: `${view.id}-group-general`,
name: t`General`,
position: 0,
isVisible: true,
fields,
},
];
}
}
const fieldsToDisplay = inlineFieldMetadataItems;
if (fieldsToDisplay.length === 0) {
return [];
}
const generalFields: Array<{
fieldMetadataItem: (typeof fieldsToDisplay)[0];
position: number;
}> = [];
const otherFields: Array<{
fieldMetadataItem: (typeof fieldsToDisplay)[0];
position: number;
}> = [];
let generalPosition = 0;
let otherPosition = 0;
fieldsToDisplay.forEach((field) => {
if (field.isCustom === true) {
otherFields.push({
fieldMetadataItem: field,
position: otherPosition++,
});
} else {
generalFields.push({
fieldMetadataItem: field,
position: generalPosition++,
});
}
});
const groups: FieldsWidgetGroup[] = [];
let globalIndex = 0;
if (generalFields.length > 0) {
groups.push({
id: `${objectNameSingular}-group-general`,
name: t`General`,
position: 0,
isVisible: true,
fields: generalFields.map((field) => ({
fieldMetadataItem: field.fieldMetadataItem,
position: field.position,
isVisible: true,
globalIndex: globalIndex++,
})),
});
}
if (otherFields.length > 0) {
groups.push({
id: `${objectNameSingular}-group-other`,
name: t`Other`,
position: 1,
isVisible: true,
fields: otherFields.map((field) => ({
fieldMetadataItem: field.fieldMetadataItem,
position: field.position,
isVisible: true,
globalIndex: globalIndex++,
})),
});
}
return groups;
}, [
objectMetadataItem,
objectNameSingular,
t,
view,
inlineFieldMetadataItems,
]);
return {
groups,
isFromView:
isDefined(view) &&
(isDefined(view.viewFieldGroups) || view.viewFields.length > 0),
};
};
@@ -0,0 +1,28 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useMemo } from 'react';
type UseFieldsWidgetGroupsDraftParams = {
pageLayoutId: string;
widgetId: string;
};
export const useFieldsWidgetGroupsDraft = ({
pageLayoutId,
widgetId,
}: UseFieldsWidgetGroupsDraftParams): {
draftGroups: FieldsWidgetGroup[];
} => {
const allDraftGroups = useRecoilComponentValue(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const draftGroups = useMemo(
() => allDraftGroups[widgetId] ?? [],
[allDraftGroups, widgetId],
);
return { draftGroups };
};
@@ -0,0 +1,52 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { filterDraftGroupsForDisplay } from '@/page-layout/widgets/fields/utils/filterDraftGroupsForDisplay';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseFieldsWidgetGroupsForDisplayParams = {
widgetId: string;
viewId: string | null;
objectNameSingular: string;
};
export const useFieldsWidgetGroupsForDisplay = ({
widgetId,
viewId,
objectNameSingular,
}: UseFieldsWidgetGroupsForDisplayParams) => {
const isPageLayoutInEditMode = useRecoilComponentValue(
isPageLayoutInEditModeComponentState,
);
const allDraftGroups = useRecoilComponentValue(
fieldsWidgetGroupsDraftComponentState,
);
const viewGroups = useFieldsWidgetGroups({
viewId,
objectNameSingular,
});
const draftGroupsForWidget = allDraftGroups[widgetId];
const hasDraftGroups =
isDefined(draftGroupsForWidget) && draftGroupsForWidget.length > 0;
const groups = useMemo<FieldsWidgetGroup[]>(() => {
if (isPageLayoutInEditMode && hasDraftGroups) {
return filterDraftGroupsForDisplay(draftGroupsForWidget);
}
return viewGroups.groups;
}, [
isPageLayoutInEditMode,
hasDraftGroups,
draftGroupsForWidget,
viewGroups.groups,
]);
return { groups };
};
@@ -1,34 +0,0 @@
import { useTemporaryFieldsConfiguration } from '@/page-layout/hooks/useTemporaryFieldsConfiguration';
import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext';
import { useFieldsWidgetFieldMetadataItems } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFieldMetadataItems';
import { filterAndOrderFieldsFromConfiguration } from '@/page-layout/widgets/fields/utils/filterAndOrderFieldsFromConfiguration';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
export const useFieldsWidgetSectionsWithFields = (
objectNameSingular: string,
) => {
const isMobile = useIsMobile();
const { isInRightDrawer } = useLayoutRenderingContext();
const { inlineFieldMetadataItems, legacyActivityTargetFieldMetadataItems } =
useFieldsWidgetFieldMetadataItems({
objectNameSingular,
});
const temporaryConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
const context = buildWidgetVisibilityContext({ isMobile, isInRightDrawer });
const allFieldMetadataItems = [
...legacyActivityTargetFieldMetadataItems,
...inlineFieldMetadataItems,
];
const sectionsWithFields = filterAndOrderFieldsFromConfiguration({
configuration: temporaryConfiguration,
availableFieldMetadataItems: allFieldMetadataItems,
context,
});
return { sectionsWithFields };
};
@@ -1,26 +0,0 @@
import { useFieldsWidgetSectionsWithFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithFields';
export const useFieldsWidgetSectionsWithIndices = (
objectNameSingular: string,
) => {
const { sectionsWithFields } =
useFieldsWidgetSectionsWithFields(objectNameSingular);
const sectionsWithFieldIndices = sectionsWithFields.map(
(section, sectionIndex) => {
const startIndex = sectionsWithFields
.slice(0, sectionIndex)
.reduce((sum, s) => sum + s.fields.length, 0);
return {
...section,
fields: section.fields.map((field, fieldIndex) => ({
field,
globalIndex: startIndex + fieldIndex,
})),
};
},
);
return { sectionsWithFieldIndices };
};
@@ -0,0 +1,84 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
type UseInitializeFieldsWidgetGroupsDraftParams = {
pageLayoutId: string;
widgetId: string;
serverGroups: FieldsWidgetGroup[];
};
export const useInitializeFieldsWidgetGroupsDraft = ({
pageLayoutId,
widgetId,
serverGroups,
}: UseInitializeFieldsWidgetGroupsDraftParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetGroupsPersistedState = useRecoilComponentCallbackState(
fieldsWidgetGroupsPersistedComponentState,
pageLayoutId,
);
const hasInitializedFieldsWidgetGroupsDraftState =
useRecoilComponentCallbackState(
hasInitializedFieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const initializeDraft = useRecoilCallback(
({ set, snapshot }) =>
() => {
const hasInitialized = snapshot
.getLoadable(hasInitializedFieldsWidgetGroupsDraftState)
.getValue();
if (hasInitialized[widgetId]) {
return;
}
const currentDraft = snapshot
.getLoadable(fieldsWidgetGroupsDraftState)
.getValue();
const hasDraftForWidget = widgetId in currentDraft;
if (!hasDraftForWidget) {
set(fieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[widgetId]: serverGroups,
}));
set(fieldsWidgetGroupsPersistedState, (prev) => ({
...prev,
[widgetId]: serverGroups,
}));
}
set(hasInitializedFieldsWidgetGroupsDraftState, (prev) => ({
...prev,
[widgetId]: true,
}));
},
[
hasInitializedFieldsWidgetGroupsDraftState,
fieldsWidgetGroupsDraftState,
fieldsWidgetGroupsPersistedState,
widgetId,
serverGroups,
],
);
useEffect(() => {
if (serverGroups.length > 0) {
initializeDraft();
}
}, [serverGroups, initializeDraft]);
};
@@ -0,0 +1,126 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
type UseMoveFieldInDraftParams = {
pageLayoutId: string;
widgetId: string;
};
export const useMoveFieldInDraft = ({
pageLayoutId,
widgetId,
}: UseMoveFieldInDraftParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const moveFieldRecoilCallback = useRecoilCallback(
({ set }) =>
(
sourceGroupId: string,
destinationGroupId: string,
sourceIndex: number,
destinationIndex: number,
) => {
set(fieldsWidgetGroupsDraftState, (prev) => {
const currentGroups = prev[widgetId] ?? [];
const sourceGroup = currentGroups.find((g) => g.id === sourceGroupId);
if (!sourceGroup) {
return prev;
}
const sortedSourceFields = [...sourceGroup.fields].sort(
(a, b) => a.position - b.position,
);
const [movedField] = sortedSourceFields.splice(sourceIndex, 1);
if (!movedField) {
return prev;
}
if (sourceGroupId === destinationGroupId) {
sortedSourceFields.splice(destinationIndex, 0, movedField);
const updatedFields = sortedSourceFields.map((field, index) => ({
...field,
position: index,
}));
return {
...prev,
[widgetId]: currentGroups.map((group) =>
group.id === sourceGroupId
? { ...group, fields: updatedFields }
: group,
),
};
}
const destinationGroup = currentGroups.find(
(g) => g.id === destinationGroupId,
);
if (!destinationGroup) {
return prev;
}
const updatedSourceFields = sortedSourceFields.map(
(field, index) => ({
...field,
position: index,
}),
);
const sortedDestFields = [...destinationGroup.fields].sort(
(a, b) => a.position - b.position,
);
sortedDestFields.splice(destinationIndex, 0, movedField);
const updatedDestFields = sortedDestFields.map((field, index) => ({
...field,
position: index,
}));
return {
...prev,
[widgetId]: currentGroups.map((group) => {
if (group.id === sourceGroupId) {
return { ...group, fields: updatedSourceFields };
}
if (group.id === destinationGroupId) {
return { ...group, fields: updatedDestFields };
}
return group;
}),
};
});
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const moveField = useCallback(
(
sourceGroupId: string,
destinationGroupId: string,
sourceIndex: number,
destinationIndex: number,
) =>
moveFieldRecoilCallback(
sourceGroupId,
destinationGroupId,
sourceIndex,
destinationIndex,
),
[moveFieldRecoilCallback],
);
return { moveField };
};
@@ -0,0 +1,54 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
type UseReorderFieldsWidgetEditorGroupsParams = {
pageLayoutId: string;
widgetId: string;
};
export const useReorderFieldsWidgetEditorGroups = ({
pageLayoutId,
widgetId,
}: UseReorderFieldsWidgetEditorGroupsParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const reorderGroupsRecoilCallback = useRecoilCallback(
({ set }) =>
(reorderedGroupIds: string[]) => {
set(fieldsWidgetGroupsDraftState, (prev) => {
const currentGroups = prev[widgetId] ?? [];
const reorderedGroups = reorderedGroupIds
.map((groupId, index) => {
const group = currentGroups.find((g) => g.id === groupId);
if (!group) {
return null;
}
return { ...group, position: index };
})
.filter((g): g is NonNullable<typeof g> => g !== null);
return {
...prev,
[widgetId]: reorderedGroups,
};
});
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const reorderGroups = useCallback(
(reorderedGroupIds: string[]) =>
reorderGroupsRecoilCallback(reorderedGroupIds),
[reorderGroupsRecoilCallback],
);
return { reorderGroups };
};
@@ -0,0 +1,53 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
type UseToggleFieldVisibilityInDraftParams = {
pageLayoutId: string;
widgetId: string;
};
export const useToggleFieldVisibilityInDraft = ({
pageLayoutId,
widgetId,
}: UseToggleFieldVisibilityInDraftParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const toggleFieldVisibilityRecoilCallback = useRecoilCallback(
({ set }) =>
(groupId: string, fieldMetadataId: string) => {
set(fieldsWidgetGroupsDraftState, (prev) => {
const currentGroups = prev[widgetId] ?? [];
return {
...prev,
[widgetId]: currentGroups.map((group) =>
group.id === groupId
? {
...group,
fields: group.fields.map((field) =>
field.fieldMetadataItem.id === fieldMetadataId
? { ...field, isVisible: !field.isVisible }
: field,
),
}
: group,
),
};
});
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const toggleFieldVisibility = useCallback(
(groupId: string, fieldMetadataId: string) =>
toggleFieldVisibilityRecoilCallback(groupId, fieldMetadataId),
[toggleFieldVisibilityRecoilCallback],
);
return { toggleFieldVisibility };
};
@@ -0,0 +1,58 @@
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
type UpdateGroupParams = {
groupId: string;
name?: string;
position?: number;
isVisible?: boolean;
};
type UseUpdateFieldsWidgetEditorGroupParams = {
pageLayoutId: string;
widgetId: string;
};
export const useUpdateFieldsWidgetEditorGroup = ({
pageLayoutId,
widgetId,
}: UseUpdateFieldsWidgetEditorGroupParams) => {
const fieldsWidgetGroupsDraftState = useRecoilComponentCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const updateGroupRecoilCallback = useRecoilCallback(
({ set }) =>
({ groupId, name, position, isVisible }: UpdateGroupParams) => {
set(fieldsWidgetGroupsDraftState, (prev) => {
const currentGroups = prev[widgetId] ?? [];
return {
...prev,
[widgetId]: currentGroups.map((group) =>
group.id === groupId
? {
...group,
...(isDefined(name) ? { name } : {}),
...(isDefined(position) ? { position } : {}),
...(isDefined(isVisible) ? { isVisible } : {}),
}
: group,
),
};
});
},
[fieldsWidgetGroupsDraftState, widgetId],
);
const updateGroup = useCallback(
(params: UpdateGroupParams) => updateGroupRecoilCallback(params),
[updateGroupRecoilCallback],
);
return { updateGroup };
};
@@ -0,0 +1,17 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
export type FieldsWidgetGroupField = {
fieldMetadataItem: FieldMetadataItem;
position: number;
isVisible: boolean;
globalIndex: number;
viewFieldId?: string;
};
export type FieldsWidgetGroup = {
id: string;
name: string;
position: number;
isVisible: boolean;
fields: FieldsWidgetGroupField[];
};
@@ -0,0 +1,187 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { filterDraftGroupsForDisplay } from '@/page-layout/widgets/fields/utils/filterDraftGroupsForDisplay';
const mockFieldMetadataItem = {
id: 'field-meta-1',
name: 'name',
label: 'Name',
type: 'TEXT',
} as FieldMetadataItem;
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
const makeField = (
overrides: Partial<FieldsWidgetGroup['fields'][number]> = {},
): FieldsWidgetGroup['fields'][number] => ({
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
...overrides,
});
describe('filterDraftGroupsForDisplay', () => {
it('should return empty array for empty input', () => {
const result = filterDraftGroupsForDisplay([]);
expect(result).toEqual([]);
});
it('should filter out non-visible groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
isVisible: true,
fields: [makeField()],
}),
makeGroup({
id: 'g2',
isVisible: false,
fields: [makeField({ viewFieldId: 'vf2' })],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result).toHaveLength(1);
expect(result[0].id).toBe('g1');
});
it('should filter out non-visible fields within groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
makeField({ isVisible: true, viewFieldId: 'vf1' }),
makeField({ isVisible: false, viewFieldId: 'vf2' }),
makeField({ isVisible: true, viewFieldId: 'vf3' }),
],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result).toHaveLength(1);
expect(result[0].fields).toHaveLength(2);
expect(result[0].fields[0].viewFieldId).toBe('vf1');
expect(result[0].fields[1].viewFieldId).toBe('vf3');
});
it('should remove groups where all fields are hidden', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [makeField({ isVisible: false })],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result).toHaveLength(0);
});
it('should sort groups by position', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g3',
position: 2,
fields: [makeField({ viewFieldId: 'vf3' })],
}),
makeGroup({
id: 'g1',
position: 0,
fields: [makeField({ viewFieldId: 'vf1' })],
}),
makeGroup({
id: 'g2',
position: 1,
fields: [makeField({ viewFieldId: 'vf2' })],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result.map((g) => g.id)).toEqual(['g1', 'g2', 'g3']);
});
it('should sort fields by position within each group', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
fields: [
makeField({ position: 2, viewFieldId: 'vf2' }),
makeField({ position: 0, viewFieldId: 'vf0' }),
makeField({ position: 1, viewFieldId: 'vf1' }),
],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result[0].fields.map((f) => f.viewFieldId)).toEqual([
'vf0',
'vf1',
'vf2',
]);
});
it('should compute correct globalIndex across groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
position: 0,
fields: [
makeField({ position: 0, viewFieldId: 'vf1' }),
makeField({ position: 1, viewFieldId: 'vf2' }),
],
}),
makeGroup({
id: 'g2',
position: 1,
fields: [makeField({ position: 0, viewFieldId: 'vf3' })],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result[0].fields[0].globalIndex).toBe(0);
expect(result[0].fields[1].globalIndex).toBe(1);
expect(result[1].fields[0].globalIndex).toBe(2);
});
it('should not count hidden fields in globalIndex', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
position: 0,
fields: [
makeField({ position: 0, isVisible: true, viewFieldId: 'vf1' }),
makeField({ position: 1, isVisible: false, viewFieldId: 'vf2' }),
],
}),
makeGroup({
id: 'g2',
position: 1,
fields: [
makeField({ position: 0, isVisible: true, viewFieldId: 'vf3' }),
],
}),
];
const result = filterDraftGroupsForDisplay(groups);
expect(result[0].fields).toHaveLength(1);
expect(result[0].fields[0].globalIndex).toBe(0);
expect(result[1].fields[0].globalIndex).toBe(1);
});
});
@@ -1,74 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { type WidgetVisibilityContext } from '@/page-layout/types/WidgetVisibilityContext';
import { evaluateWidgetVisibility } from '@/page-layout/utils/evaluateWidgetVisibility';
import { isDefined } from 'twenty-shared/utils';
export type FieldsConfigurationSectionWithFields = {
id: string;
title: string;
position: number;
fields: FieldMetadataItem[];
};
type FilterAndOrderFieldsFromConfigurationParams = {
configuration: FieldsConfiguration;
availableFieldMetadataItems: FieldMetadataItem[];
context: WidgetVisibilityContext;
};
export const filterAndOrderFieldsFromConfiguration = ({
configuration,
availableFieldMetadataItems,
context,
}: FilterAndOrderFieldsFromConfigurationParams): FieldsConfigurationSectionWithFields[] => {
const sortedSections = [...configuration.sections].sort(
(a, b) => a.position - b.position,
);
const sectionsWithFields = sortedSections
.map((section) => {
const sectionFieldIds = new Set(
section.fields.map((f) => f.fieldMetadataId),
);
const visibleFields = availableFieldMetadataItems
.filter((fieldMetadataItem) =>
sectionFieldIds.has(fieldMetadataItem.id),
)
.filter((fieldMetadataItem) => {
const fieldConfig = section.fields.find(
(f) => f.fieldMetadataId === fieldMetadataItem.id,
);
if (!isDefined(fieldConfig)) {
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,
});
});
if (visibleFields.length === 0) {
return null;
}
return {
id: section.id,
title: section.title,
position: section.position,
fields: visibleFields,
};
})
.filter(isDefined);
return sectionsWithFields;
};
@@ -0,0 +1,27 @@
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
export const filterDraftGroupsForDisplay = (
draftGroups: FieldsWidgetGroup[],
): FieldsWidgetGroup[] => {
const sortedGroups = [...draftGroups].sort((a, b) => a.position - b.position);
let globalIndex = 0;
return sortedGroups
.filter((group) => group.isVisible)
.map((group) => {
const visibleFields = [...group.fields]
.sort((a, b) => a.position - b.position)
.filter((field) => field.isVisible)
.map((field) => ({
...field,
globalIndex: globalIndex++,
}));
return {
...group,
fields: visibleFields,
};
})
.filter((group) => group.fields.length > 0);
};
@@ -1,8 +1,10 @@
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { isDefined } from 'twenty-shared/utils';
import { type WidgetConfiguration } from '~/generated-metadata/graphql';
import {
type FieldsConfiguration,
type WidgetConfiguration,
} from '~/generated-metadata/graphql';
export const hasMinimalRequiredConfigForGraph = (
configuration: WidgetConfiguration | FieldsConfiguration | FieldConfiguration,
@@ -0,0 +1,91 @@
import { getWidgetCardVariant } from '@/page-layout/widgets/utils/getWidgetCardVariant';
import {
PageLayoutTabLayoutMode,
PageLayoutType,
} from '~/generated-metadata/graphql';
describe('getWidgetCardVariant', () => {
it('should return dashboard for DASHBOARD page layout type', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.DASHBOARD,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('dashboard');
});
it('should return canvas for CANVAS layout mode', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.CANVAS,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.RECORD_PAGE,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('canvas');
});
it('should return side-column when isInPinnedTab is true', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: true,
pageLayoutType: PageLayoutType.RECORD_PAGE,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('side-column');
});
it('should return side-column when isMobile is true', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.RECORD_PAGE,
isMobile: true,
isInRightDrawer: false,
}),
).toBe('side-column');
});
it('should return side-column when isInRightDrawer is true', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.RECORD_PAGE,
isMobile: false,
isInRightDrawer: true,
}),
).toBe('side-column');
});
it('should return record-page as default', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.GRID,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.RECORD_PAGE,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('record-page');
});
it('should prioritize dashboard over canvas', () => {
expect(
getWidgetCardVariant({
layoutMode: PageLayoutTabLayoutMode.CANVAS,
isInPinnedTab: false,
pageLayoutType: PageLayoutType.DASHBOARD,
isMobile: false,
isInRightDrawer: false,
}),
).toBe('dashboard');
});
});
@@ -26,11 +26,13 @@ import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { AppPath, type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type WorkspaceMember,
ViewType as CoreViewType,
useFindAllCoreViewsQuery,
useFindAllRecordPageLayoutsQuery,
useGetCurrentUserQuery,
useFindFieldsWidgetCoreViewsQuery,
useFindManyLogicFunctionsQuery,
useGetCurrentUserQuery,
type WorkspaceMember,
} from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dateLocaleStateV2 } from '~/localization/states/dateLocaleStateV2';
@@ -38,6 +40,14 @@ import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const INDEX_VIEW_TYPES = [
CoreViewType.TABLE,
CoreViewType.KANBAN,
CoreViewType.CALENDAR,
];
const FIELDS_WIDGET_VIEW_TYPES = [CoreViewType.FIELDS_WIDGET];
export const MetadataProviderEffect = () => {
const location = useLocation();
@@ -77,15 +87,41 @@ export const MetadataProviderEffect = () => {
[store],
);
const setCoreViews = useRecoilCallback(
const setIndexCoreViews = useRecoilCallback(
({ set, snapshot }) =>
(coreViews: CoreViewWithRelations[]) => {
(indexViews: CoreViewWithRelations[]) => {
const existingCoreViews = snapshot
.getLoadable(coreViewsState)
.getValue();
if (!isDeeplyEqual(existingCoreViews, coreViews)) {
set(coreViewsState, coreViews);
const existingFieldsWidgetViews = existingCoreViews.filter(
(view) => view.type === CoreViewType.FIELDS_WIDGET,
);
const mergedViews = [...indexViews, ...existingFieldsWidgetViews];
if (!isDeeplyEqual(existingCoreViews, mergedViews)) {
set(coreViewsState, mergedViews);
}
},
[],
);
const setFieldsWidgetCoreViews = useRecoilCallback(
({ set, snapshot }) =>
(fieldsWidgetViews: CoreViewWithRelations[]) => {
const existingCoreViews = snapshot
.getLoadable(coreViewsState)
.getValue();
const existingIndexViews = existingCoreViews.filter(
(view) => view.type !== CoreViewType.FIELDS_WIDGET,
);
const mergedViews = [...existingIndexViews, ...fieldsWidgetViews];
if (!isDeeplyEqual(existingCoreViews, mergedViews)) {
set(coreViewsState, mergedViews);
}
},
[],
@@ -129,6 +165,13 @@ export const MetadataProviderEffect = () => {
const { data: queryDataCoreViews, loading: queryLoadingCoreViews } =
useFindAllCoreViewsQuery({
skip: shouldSkip,
variables: { viewTypes: INDEX_VIEW_TYPES },
});
const { data: queryDataFieldsWidgetCoreViews } =
useFindFieldsWidgetCoreViewsQuery({
skip: shouldSkip,
variables: { viewTypes: FIELDS_WIDGET_VIEW_TYPES },
});
const { data: queryDataRecordPageLayouts } = useFindAllRecordPageLayoutsQuery(
@@ -239,7 +282,7 @@ export const MetadataProviderEffect = () => {
initializeFormatPreferences,
setCurrentWorkspaceMembersWithDeleted,
updateLocaleCatalog,
setCoreViews,
setIndexCoreViews,
]);
useEffect(() => {
@@ -249,8 +292,18 @@ export const MetadataProviderEffect = () => {
if (!isDefined(queryDataCoreViews?.getCoreViews)) return;
setCoreViews(queryDataCoreViews.getCoreViews);
}, [queryDataCoreViews?.getCoreViews, setCoreViews, queryLoadingCoreViews]);
setIndexCoreViews(queryDataCoreViews.getCoreViews);
}, [
queryDataCoreViews?.getCoreViews,
setIndexCoreViews,
queryLoadingCoreViews,
]);
useEffect(() => {
if (!isDefined(queryDataFieldsWidgetCoreViews?.getCoreViews)) return;
setFieldsWidgetCoreViews(queryDataFieldsWidgetCoreViews.getCoreViews);
}, [queryDataFieldsWidgetCoreViews?.getCoreViews, setFieldsWidgetCoreViews]);
useEffect(() => {
if (!isDefined(queryDataRecordPageLayouts?.getPageLayouts)) return;
@@ -0,0 +1,19 @@
import { VIEW_FIELD_FRAGMENT } from '@/views/graphql/fragments/viewFieldFragment';
import { gql } from '@apollo/client';
export const VIEW_FIELD_GROUP_FRAGMENT = gql`
${VIEW_FIELD_FRAGMENT}
fragment ViewFieldGroupFragment on CoreViewFieldGroup {
id
name
position
isVisible
viewId
createdAt
updatedAt
deletedAt
viewFields {
...ViewFieldFragment
}
}
`;
@@ -1,4 +1,5 @@
import { VIEW_FIELD_FRAGMENT } from '@/views/graphql/fragments/viewFieldFragment';
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { VIEW_FILTER_FRAGMENT } from '@/views/graphql/fragments/viewFilterFragment';
import { VIEW_FILTER_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFilterGroupFragment';
import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment';
@@ -6,6 +7,7 @@ import { VIEW_SORT_FRAGMENT } from '@/views/graphql/fragments/viewSortFragment';
import { gql } from '@apollo/client';
export const VIEW_FRAGMENT = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
${VIEW_FIELD_FRAGMENT}
${VIEW_FILTER_FRAGMENT}
${VIEW_FILTER_GROUP_FRAGMENT}
@@ -34,6 +36,9 @@ export const VIEW_FRAGMENT = gql`
viewFields {
...ViewFieldFragment
}
viewFieldGroups {
...ViewFieldGroupFragment
}
viewFilters {
...ViewFilterFragment
}
@@ -0,0 +1,11 @@
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { gql } from '@apollo/client';
export const CREATE_CORE_VIEW_FIELD_GROUP = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
mutation CreateCoreViewFieldGroup($input: CreateViewFieldGroupInput!) {
createCoreViewFieldGroup(input: $input) {
...ViewFieldGroupFragment
}
}
`;
@@ -0,0 +1,13 @@
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { gql } from '@apollo/client';
export const CREATE_MANY_CORE_VIEW_FIELD_GROUPS = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
mutation CreateManyCoreViewFieldGroups(
$inputs: [CreateViewFieldGroupInput!]!
) {
createManyCoreViewFieldGroups(inputs: $inputs) {
...ViewFieldGroupFragment
}
}
`;
@@ -0,0 +1,11 @@
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { gql } from '@apollo/client';
export const DELETE_CORE_VIEW_FIELD_GROUP = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
mutation DeleteCoreViewFieldGroup($input: DeleteViewFieldGroupInput!) {
deleteCoreViewFieldGroup(input: $input) {
...ViewFieldGroupFragment
}
}
`;
@@ -0,0 +1,11 @@
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { gql } from '@apollo/client';
export const UPDATE_CORE_VIEW_FIELD_GROUP = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
mutation UpdateCoreViewFieldGroup($input: UpdateViewFieldGroupInput!) {
updateCoreViewFieldGroup(input: $input) {
...ViewFieldGroupFragment
}
}
`;
@@ -3,8 +3,8 @@ import { gql } from '@apollo/client';
export const FIND_ALL_CORE_VIEWS = gql`
${VIEW_FRAGMENT}
query FindAllCoreViews {
getCoreViews {
query FindAllCoreViews($viewTypes: [ViewType!]) {
getCoreViews(viewTypes: $viewTypes) {
...ViewFragment
}
}
@@ -0,0 +1,11 @@
import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment';
import { gql } from '@apollo/client';
export const FIND_FIELDS_WIDGET_CORE_VIEWS = gql`
${VIEW_FRAGMENT}
query FindFieldsWidgetCoreViews($viewTypes: [ViewType!]) {
getCoreViews(viewTypes: $viewTypes) {
...ViewFragment
}
}
`;
@@ -3,8 +3,8 @@ import { gql } from '@apollo/client';
export const FIND_MANY_CORE_VIEWS = gql`
${VIEW_FRAGMENT}
query FindManyCoreViews($objectMetadataId: String) {
getCoreViews(objectMetadataId: $objectMetadataId) {
query FindManyCoreViews($objectMetadataId: String, $viewTypes: [ViewType!]) {
getCoreViews(objectMetadataId: $objectMetadataId, viewTypes: $viewTypes) {
...ViewFragment
}
}

Some files were not shown because too many files have changed in this diff Show More