Create fake hidden fields group (#18525)
## Demo https://github.com/user-attachments/assets/43f31c43-fe37-4553-ad42-fc97a948d6ea ## Ungrouped fields <img width="3456" height="2160" alt="CleanShot 2026-03-10 at 13 52 57@2x" src="https://github.com/user-attachments/assets/13d1db63-59ac-4e2b-8950-fccf430176c4" />
This commit is contained in:
committed by
GitHub
parent
8e003aa6cf
commit
926dd545f4
File diff suppressed because one or more lines are too long
@@ -1,6 +1,12 @@
|
||||
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 { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
|
||||
import { fieldsWidgetEditorModePersistedComponentState } from '@/page-layout/states/fieldsWidgetEditorModePersistedComponentState';
|
||||
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
|
||||
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
|
||||
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
|
||||
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
|
||||
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
|
||||
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
|
||||
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
|
||||
@@ -9,9 +15,9 @@ import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/page
|
||||
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
|
||||
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
|
||||
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useStore } from 'jotai';
|
||||
|
||||
export const useExecuteTasksOnAnyLocationChange = () => {
|
||||
const store = useStore();
|
||||
@@ -71,6 +77,48 @@ export const useExecuteTasksOnAnyLocationChange = () => {
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetGroupsDraftComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetGroupsPersistedComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetUngroupedFieldsDraftComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetUngroupedFieldsPersistedComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetEditorModeDraftComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(
|
||||
fieldsWidgetEditorModePersistedComponentState.atomFamily({
|
||||
instanceId: pageLayoutId,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
|
||||
store.set(currentPageLayoutIdState.atom, null);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -167,6 +167,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
|
||||
... on FieldsConfiguration {
|
||||
configurationType
|
||||
viewId
|
||||
shouldAllowUserToSeeHiddenFields
|
||||
newFieldDefaultConfiguration {
|
||||
isVisible
|
||||
viewFieldGroupId
|
||||
|
||||
+39
-2
@@ -1,10 +1,14 @@
|
||||
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
|
||||
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
|
||||
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
|
||||
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
|
||||
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
|
||||
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
|
||||
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
|
||||
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
|
||||
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
|
||||
import { useStore } from 'jotai';
|
||||
@@ -27,10 +31,39 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
|
||||
MAIN_CONTEXT_STORE_INSTANCE_ID,
|
||||
);
|
||||
|
||||
const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState(
|
||||
fieldsWidgetGroupsDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const fieldsWidgetUngroupedFieldsDraftState =
|
||||
useAtomComponentStateCallbackState(
|
||||
fieldsWidgetUngroupedFieldsDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState(
|
||||
fieldsWidgetEditorModeDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const hasInitializedFieldsWidgetGroupsDraftState =
|
||||
useAtomComponentStateCallbackState(
|
||||
hasInitializedFieldsWidgetGroupsDraftComponentState,
|
||||
pageLayoutId,
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const setIsPageLayoutInEditMode = useCallback(
|
||||
(value: boolean) => {
|
||||
if (value) {
|
||||
store.set(fieldsWidgetGroupsDraftState, {});
|
||||
store.set(fieldsWidgetUngroupedFieldsDraftState, {});
|
||||
store.set(fieldsWidgetEditorModeDraftState, {});
|
||||
store.set(hasInitializedFieldsWidgetGroupsDraftState, {});
|
||||
}
|
||||
|
||||
store.set(isPageLayoutInEditModeState, value);
|
||||
|
||||
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
|
||||
@@ -51,6 +84,10 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
|
||||
[
|
||||
isPageLayoutInEditModeState,
|
||||
contextStoreIsFullTabWidgetInEditModeState,
|
||||
fieldsWidgetGroupsDraftState,
|
||||
fieldsWidgetUngroupedFieldsDraftState,
|
||||
fieldsWidgetEditorModeDraftState,
|
||||
hasInitializedFieldsWidgetGroupsDraftState,
|
||||
pageLayoutId,
|
||||
store,
|
||||
],
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
|
||||
import { assertPageLayoutTabHasDefinedLayoutModeOrThrow } from '@/page-layout/utils/assertPageLayoutTabHasDefinedLayoutModeOrThrow';
|
||||
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('assertPageLayoutTabHasDefinedLayoutModeOrThrow', () => {
|
||||
it('should throw when tab is undefined', () => {
|
||||
expect(() => {
|
||||
assertPageLayoutTabHasDefinedLayoutModeOrThrow(undefined);
|
||||
}).toThrow('Tab layout mode is not defined');
|
||||
});
|
||||
|
||||
it('should throw when tab has null layoutMode', () => {
|
||||
const tab = {
|
||||
layoutMode: null,
|
||||
} as unknown as PageLayoutTab;
|
||||
|
||||
expect(() => {
|
||||
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
|
||||
}).toThrow('Tab layout mode is not defined');
|
||||
});
|
||||
|
||||
it('should throw when tab has undefined layoutMode', () => {
|
||||
const tab = {
|
||||
layoutMode: undefined,
|
||||
} as unknown as PageLayoutTab;
|
||||
|
||||
expect(() => {
|
||||
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
|
||||
}).toThrow('Tab layout mode is not defined');
|
||||
});
|
||||
|
||||
it('should not throw when tab has a valid layoutMode', () => {
|
||||
const tab = {
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
} as unknown as PageLayoutTab;
|
||||
|
||||
expect(() => {
|
||||
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for VERTICAL_LIST layoutMode', () => {
|
||||
const tab = {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
} as unknown as PageLayoutTab;
|
||||
|
||||
expect(() => {
|
||||
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext';
|
||||
|
||||
describe('buildWidgetVisibilityContext', () => {
|
||||
it('should return MOBILE device when isMobile is true', () => {
|
||||
const result = buildWidgetVisibilityContext({
|
||||
isMobile: true,
|
||||
isInSidePanel: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ device: 'MOBILE' });
|
||||
});
|
||||
|
||||
it('should return MOBILE device when isInSidePanel is true', () => {
|
||||
const result = buildWidgetVisibilityContext({
|
||||
isMobile: false,
|
||||
isInSidePanel: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ device: 'MOBILE' });
|
||||
});
|
||||
|
||||
it('should return MOBILE device when both isMobile and isInSidePanel are true', () => {
|
||||
const result = buildWidgetVisibilityContext({
|
||||
isMobile: true,
|
||||
isInSidePanel: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ device: 'MOBILE' });
|
||||
});
|
||||
|
||||
it('should return DESKTOP device when both are false', () => {
|
||||
const result = buildWidgetVisibilityContext({
|
||||
isMobile: false,
|
||||
isInSidePanel: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ device: 'DESKTOP' });
|
||||
});
|
||||
});
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
|
||||
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
|
||||
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
|
||||
import { getRecordPageLayoutId } from '@/page-layout/utils/getRecordPageLayoutId';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
|
||||
const createMockRecord = (
|
||||
overrides: Partial<ObjectRecord> = {},
|
||||
): ObjectRecord => ({
|
||||
id: 'record-1',
|
||||
__typename: 'ObjectRecord',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getRecordPageLayoutId', () => {
|
||||
it('should return null when record is null', () => {
|
||||
const result = getRecordPageLayoutId({
|
||||
record: null,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Company,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when record is undefined', () => {
|
||||
const result = getRecordPageLayoutId({
|
||||
record: undefined,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Company,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return record.pageLayoutId when it is defined', () => {
|
||||
const record = createMockRecord({ pageLayoutId: 'custom-layout-123' });
|
||||
|
||||
const result = getRecordPageLayoutId({
|
||||
record,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Company,
|
||||
});
|
||||
|
||||
expect(result).toBe('custom-layout-123');
|
||||
});
|
||||
|
||||
it('should return Company default layout for Company object', () => {
|
||||
const record = createMockRecord();
|
||||
|
||||
const result = getRecordPageLayoutId({
|
||||
record,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Company,
|
||||
});
|
||||
|
||||
expect(result).toBe(DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID);
|
||||
});
|
||||
|
||||
it('should return Person default layout for Person object', () => {
|
||||
const record = createMockRecord();
|
||||
|
||||
const result = getRecordPageLayoutId({
|
||||
record,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Person,
|
||||
});
|
||||
|
||||
expect(result).toBe(DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID);
|
||||
});
|
||||
|
||||
it('should return generic default layout for unknown object type', () => {
|
||||
const record = createMockRecord();
|
||||
|
||||
const result = getRecordPageLayoutId({
|
||||
record,
|
||||
targetObjectNameSingular: 'customObject',
|
||||
});
|
||||
|
||||
expect(result).toBe(DEFAULT_RECORD_PAGE_LAYOUT_ID);
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('getTabListInstanceIdFromPageLayoutAndRecord', () => {
|
||||
it('should include record ID for RECORD_PAGE layout with targetRecordIdentifier', () => {
|
||||
const result = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId: 'layout-1',
|
||||
layoutType: PageLayoutType.RECORD_PAGE,
|
||||
targetRecordIdentifier: {
|
||||
id: 'record-42',
|
||||
targetObjectNameSingular: 'company',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe('layout-1-tab-list-record-42');
|
||||
});
|
||||
|
||||
it('should omit record ID for DASHBOARD layout', () => {
|
||||
const result = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId: 'layout-1',
|
||||
layoutType: PageLayoutType.DASHBOARD,
|
||||
targetRecordIdentifier: {
|
||||
id: 'record-42',
|
||||
targetObjectNameSingular: 'company',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe('layout-1-tab-list');
|
||||
});
|
||||
|
||||
it('should omit record ID when targetRecordIdentifier is undefined', () => {
|
||||
const result = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId: 'layout-1',
|
||||
layoutType: PageLayoutType.RECORD_PAGE,
|
||||
targetRecordIdentifier: undefined,
|
||||
});
|
||||
|
||||
expect(result).toBe('layout-1-tab-list');
|
||||
});
|
||||
|
||||
it('should use the base instance ID from getTabListInstanceIdFromPageLayoutId', () => {
|
||||
const result = getTabListInstanceIdFromPageLayoutAndRecord({
|
||||
pageLayoutId: 'my-custom-layout',
|
||||
layoutType: PageLayoutType.DASHBOARD,
|
||||
});
|
||||
|
||||
expect(result).toBe('my-custom-layout-tab-list');
|
||||
});
|
||||
});
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type PageLayout } from '@/page-layout/types/PageLayout';
|
||||
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { injectRelationWidgetsIntoLayout } from '@/page-layout/utils/injectRelationWidgetsIntoLayout';
|
||||
import {
|
||||
WidgetConfigurationType,
|
||||
WidgetType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const createMockWidget = (
|
||||
id: string,
|
||||
type: WidgetType = WidgetType.FIELDS,
|
||||
): PageLayoutWidget =>
|
||||
({
|
||||
__typename: 'PageLayoutWidget',
|
||||
id,
|
||||
pageLayoutTabId: 'tab-1',
|
||||
title: `Widget ${id}`,
|
||||
type,
|
||||
objectMetadataId: null,
|
||||
gridPosition: {
|
||||
__typename: 'GridPosition',
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 12,
|
||||
},
|
||||
position: {
|
||||
__typename: 'PageLayoutWidgetGridPosition',
|
||||
layoutMode: 'GRID',
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 12,
|
||||
},
|
||||
configuration: {
|
||||
__typename: 'FieldsConfiguration',
|
||||
configurationType: WidgetConfigurationType.FIELDS,
|
||||
viewId: null,
|
||||
},
|
||||
isOverridden: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
}) as PageLayoutWidget;
|
||||
|
||||
const createMockTab = (
|
||||
id: string,
|
||||
widgets: PageLayoutWidget[],
|
||||
): PageLayoutTab =>
|
||||
({
|
||||
__typename: 'PageLayoutTab',
|
||||
applicationId: '',
|
||||
id,
|
||||
pageLayoutId: 'page-layout-1',
|
||||
title: `Tab ${id}`,
|
||||
position: 0,
|
||||
widgets,
|
||||
isOverridden: false,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
deletedAt: null,
|
||||
}) as PageLayoutTab;
|
||||
|
||||
const createMockLayout = (tabs: PageLayoutTab[]): PageLayout =>
|
||||
({
|
||||
__typename: 'PageLayout',
|
||||
id: 'layout-1',
|
||||
tabs,
|
||||
}) as PageLayout;
|
||||
|
||||
const createMockFieldMetadataItem = (
|
||||
id: string,
|
||||
label: string,
|
||||
): FieldMetadataItem =>
|
||||
({
|
||||
id,
|
||||
label,
|
||||
name: label.toLowerCase(),
|
||||
type: 'RELATION',
|
||||
}) as FieldMetadataItem;
|
||||
|
||||
describe('injectRelationWidgetsIntoLayout', () => {
|
||||
it('should return layout unchanged when relation fields array is empty', () => {
|
||||
const layout = createMockLayout([
|
||||
createMockTab('tab-1', [createMockWidget('w1')]),
|
||||
]);
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, []);
|
||||
|
||||
expect(result).toBe(layout);
|
||||
});
|
||||
|
||||
it('should return layout unchanged when there are no tabs', () => {
|
||||
const layout = createMockLayout([]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
expect(result).toBe(layout);
|
||||
});
|
||||
|
||||
it('should append relation widgets when no FIELDS widget exists', () => {
|
||||
const otherWidget = createMockWidget('w1', WidgetType.TIMELINE);
|
||||
const layout = createMockLayout([createMockTab('tab-1', [otherWidget])]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
expect(result.tabs[0].widgets).toHaveLength(2);
|
||||
expect(result.tabs[0].widgets[0].id).toBe('w1');
|
||||
expect(result.tabs[0].widgets[1].id).toContain('dynamic-relation-widget-');
|
||||
expect(result.tabs[0].widgets[1].id).toContain('f1');
|
||||
});
|
||||
|
||||
it('should inject relation widgets after the first FIELDS widget', () => {
|
||||
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
|
||||
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
|
||||
const layout = createMockLayout([
|
||||
createMockTab('tab-1', [fieldsWidget, timelineWidget]),
|
||||
]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
expect(result.tabs[0].widgets).toHaveLength(3);
|
||||
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
|
||||
expect(result.tabs[0].widgets[1].id).toContain('f1');
|
||||
expect(result.tabs[0].widgets[2].id).toBe('timeline-1');
|
||||
});
|
||||
|
||||
it('should reposition NOTES widget after relation widgets', () => {
|
||||
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
|
||||
const notesWidget = createMockWidget('notes-1', WidgetType.NOTES);
|
||||
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
|
||||
const layout = createMockLayout([
|
||||
createMockTab('tab-1', [fieldsWidget, notesWidget, timelineWidget]),
|
||||
]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
const widgetIds = result.tabs[0].widgets.map((w) => w.id);
|
||||
|
||||
expect(widgetIds[0]).toBe('fields-1');
|
||||
expect(widgetIds[1]).toContain('f1');
|
||||
expect(widgetIds[2]).toBe('notes-1');
|
||||
expect(widgetIds[3]).toBe('timeline-1');
|
||||
});
|
||||
|
||||
it('should only modify the first tab', () => {
|
||||
const layout = createMockLayout([
|
||||
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
|
||||
createMockTab('tab-2', [createMockWidget('fields-2', WidgetType.FIELDS)]),
|
||||
]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
expect(result.tabs[0].widgets).toHaveLength(2);
|
||||
expect(result.tabs[1].widgets).toHaveLength(1);
|
||||
expect(result.tabs[1].widgets[0].id).toBe('fields-2');
|
||||
});
|
||||
|
||||
it('should inject multiple relation widgets in order', () => {
|
||||
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
|
||||
const layout = createMockLayout([createMockTab('tab-1', [fieldsWidget])]);
|
||||
const fields = [
|
||||
createMockFieldMetadataItem('f1', 'Company'),
|
||||
createMockFieldMetadataItem('f2', 'Person'),
|
||||
];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
expect(result.tabs[0].widgets).toHaveLength(3);
|
||||
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
|
||||
expect(result.tabs[0].widgets[1].id).toContain('f1');
|
||||
expect(result.tabs[0].widgets[2].id).toContain('f2');
|
||||
});
|
||||
|
||||
it('should set correct properties on injected relation widgets', () => {
|
||||
const layout = createMockLayout([
|
||||
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
|
||||
]);
|
||||
const fields = [createMockFieldMetadataItem('f1', 'Company')];
|
||||
|
||||
const result = injectRelationWidgetsIntoLayout(layout, fields);
|
||||
|
||||
const injectedWidget = result.tabs[0].widgets[1];
|
||||
|
||||
expect(injectedWidget.type).toBe(WidgetType.FIELD);
|
||||
expect(injectedWidget.title).toBe('Company');
|
||||
expect(injectedWidget.pageLayoutTabId).toBe('tab-1');
|
||||
expect(injectedWidget.configuration).toEqual(
|
||||
expect.objectContaining({
|
||||
fieldMetadataId: 'f1',
|
||||
layout: 'CARD',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { type PageLayout } from '@/page-layout/types/PageLayout';
|
||||
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
|
||||
|
||||
const createMockLayout = (tabWidgetCounts: number[]): PageLayout =>
|
||||
({
|
||||
__typename: 'PageLayout',
|
||||
id: 'layout-1',
|
||||
tabs: tabWidgetCounts.map((widgetCount, index) => ({
|
||||
__typename: 'PageLayoutTab',
|
||||
id: `tab-${index}`,
|
||||
widgets: Array.from({ length: widgetCount }, (_, i) => ({
|
||||
__typename: 'PageLayoutWidget',
|
||||
id: `widget-${index}-${i}`,
|
||||
})),
|
||||
})),
|
||||
}) as PageLayout;
|
||||
|
||||
describe('isPageLayoutEmpty', () => {
|
||||
it('should return true when layout has one tab with no widgets', () => {
|
||||
const layout = createMockLayout([0]);
|
||||
|
||||
expect(isPageLayoutEmpty(layout)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when layout has one tab with widgets', () => {
|
||||
const layout = createMockLayout([2]);
|
||||
|
||||
expect(isPageLayoutEmpty(layout)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when layout has multiple tabs even with no widgets', () => {
|
||||
const layout = createMockLayout([0, 0]);
|
||||
|
||||
expect(isPageLayoutEmpty(layout)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when layout has multiple tabs with widgets', () => {
|
||||
const layout = createMockLayout([1, 3]);
|
||||
|
||||
expect(isPageLayoutEmpty(layout)).toBe(false);
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { removeTabLayouts } from '@/page-layout/utils/removeTabLayouts';
|
||||
|
||||
describe('removeTabLayouts', () => {
|
||||
it('should remove the specified tab and return the rest', () => {
|
||||
const allTabLayouts = {
|
||||
'tab-1': { lg: [], md: [] },
|
||||
'tab-2': { lg: [], md: [] },
|
||||
'tab-3': { lg: [], md: [] },
|
||||
};
|
||||
|
||||
const result = removeTabLayouts(allTabLayouts, 'tab-2');
|
||||
|
||||
expect(result).toEqual({
|
||||
'tab-1': { lg: [], md: [] },
|
||||
'tab-3': { lg: [], md: [] },
|
||||
});
|
||||
expect(result).not.toHaveProperty('tab-2');
|
||||
});
|
||||
|
||||
it('should return the same object reference when tab does not exist', () => {
|
||||
const allTabLayouts = {
|
||||
'tab-1': { lg: [], md: [] },
|
||||
};
|
||||
|
||||
const result = removeTabLayouts(allTabLayouts, 'non-existent');
|
||||
|
||||
expect(result).toBe(allTabLayouts);
|
||||
});
|
||||
|
||||
it('should return empty object when removing the only tab', () => {
|
||||
const allTabLayouts = {
|
||||
'tab-1': { lg: [], md: [] },
|
||||
};
|
||||
|
||||
const result = removeTabLayouts(allTabLayouts, 'tab-1');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should not mutate the original object', () => {
|
||||
const allTabLayouts = {
|
||||
'tab-1': { lg: [], md: [] },
|
||||
'tab-2': { lg: [], md: [] },
|
||||
};
|
||||
|
||||
removeTabLayouts(allTabLayouts, 'tab-1');
|
||||
|
||||
expect(allTabLayouts).toHaveProperty('tab-1');
|
||||
expect(allTabLayouts).toHaveProperty('tab-2');
|
||||
});
|
||||
});
|
||||
+48
-6
@@ -7,6 +7,7 @@ import { FieldsWidgetCellHoveredPortal } from '@/page-layout/widgets/fields/comp
|
||||
import { FieldsWidgetFieldList } from '@/page-layout/widgets/fields/components/FieldsWidgetFieldList';
|
||||
import { FieldsWidgetGroupContainer } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupContainer';
|
||||
import { useFieldsWidgetGroupsForDisplay } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay';
|
||||
import { useFieldsWidgetHiddenFieldsForDisplay } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetHiddenFieldsForDisplay';
|
||||
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
||||
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
|
||||
@@ -40,13 +41,16 @@ const StyledPropertyBox = styled.div`
|
||||
padding-top: ${themeCssVariables.spacing[3]};
|
||||
`;
|
||||
|
||||
const StyledInlineFieldsPropertyBox = styled.div`
|
||||
const StyledInlineFieldsPropertyBox = styled.div<{
|
||||
hasMoreSection: boolean;
|
||||
}>`
|
||||
align-self: stretch;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding-bottom: 0;
|
||||
padding-bottom: ${({ hasMoreSection }) =>
|
||||
hasMoreSection ? themeCssVariables.spacing[3] : '0'};
|
||||
padding-top: 0;
|
||||
`;
|
||||
|
||||
@@ -72,9 +76,31 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
|
||||
objectNameSingular: targetRecord.targetObjectNameSingular,
|
||||
});
|
||||
|
||||
const flattenedFieldMetadataItems = groups.flatMap((group) =>
|
||||
group.fields.map((field) => field.fieldMetadataItem),
|
||||
);
|
||||
const { hiddenFields } = useFieldsWidgetHiddenFieldsForDisplay({
|
||||
widgetId: widget.id,
|
||||
viewId: fieldsConfiguration.viewId ?? null,
|
||||
objectNameSingular: targetRecord.targetObjectNameSingular,
|
||||
});
|
||||
|
||||
const shouldShowHiddenFields =
|
||||
fieldsConfiguration.shouldAllowUserToSeeHiddenFields === true &&
|
||||
hiddenFields.length > 0;
|
||||
|
||||
const visibleFields = groups.flatMap((group) => group.fields);
|
||||
|
||||
const hiddenFieldsWithOffsetGlobalIndex = shouldShowHiddenFields
|
||||
? hiddenFields.map((field) => ({
|
||||
...field,
|
||||
globalIndex: field.globalIndex + visibleFields.length,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const flattenedFieldMetadataItems = [
|
||||
...visibleFields.map((field) => field.fieldMetadataItem),
|
||||
...hiddenFieldsWithOffsetGlobalIndex.map(
|
||||
(field) => field.fieldMetadataItem,
|
||||
),
|
||||
];
|
||||
|
||||
const hasFieldsToDisplay = groups.length > 0;
|
||||
|
||||
@@ -110,7 +136,9 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
|
||||
}}
|
||||
>
|
||||
{displayMode === 'inline' ? (
|
||||
<StyledInlineFieldsPropertyBox>
|
||||
<StyledInlineFieldsPropertyBox
|
||||
hasMoreSection={shouldShowHiddenFields}
|
||||
>
|
||||
<FieldsWidgetFieldList
|
||||
fields={groups.flatMap((group) => group.fields)}
|
||||
instanceId={instanceId}
|
||||
@@ -129,6 +157,20 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
|
||||
))
|
||||
)}
|
||||
|
||||
{shouldShowHiddenFields && (
|
||||
<FieldsWidgetGroupContainer
|
||||
title={t`More (${hiddenFieldsWithOffsetGlobalIndex.length})`}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<StyledPropertyBox>
|
||||
<FieldsWidgetFieldList
|
||||
fields={hiddenFieldsWithOffsetGlobalIndex}
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
</StyledPropertyBox>
|
||||
</FieldsWidgetGroupContainer>
|
||||
)}
|
||||
|
||||
<FieldsWidgetCellHoveredPortal
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
recordId={targetRecord.id}
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@ import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { IconChevronDown } from 'twenty-ui/display';
|
||||
import { AnimatedExpandableContainer, Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables, ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledHeader = styled.header`
|
||||
align-items: center;
|
||||
@@ -29,14 +29,16 @@ const StyledChevronWrapper = styled.div<{ isExpanded: boolean }>`
|
||||
type FieldsWidgetGroupContainerProps = {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
defaultExpanded?: boolean;
|
||||
};
|
||||
|
||||
export const FieldsWidgetGroupContainer = ({
|
||||
children,
|
||||
title,
|
||||
defaultExpanded = true,
|
||||
}: FieldsWidgetGroupContainerProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleToggleGroup = () =>
|
||||
setIsExpanded((previousIsExpanded) => !previousIsExpanded);
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import {
|
||||
type FieldsWidgetGroup,
|
||||
type FieldsWidgetGroupField,
|
||||
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
|
||||
import { useViewById } from '@/views/hooks/useViewById';
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
type UseFieldsWidgetHiddenFieldsParams = {
|
||||
viewId: string | null;
|
||||
objectNameSingular: string;
|
||||
};
|
||||
|
||||
export const useFieldsWidgetHiddenFields = ({
|
||||
viewId,
|
||||
objectNameSingular,
|
||||
}: UseFieldsWidgetHiddenFieldsParams) => {
|
||||
const { view } = useViewById(viewId);
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const hiddenFields = useMemo<FieldsWidgetGroupField[]>(() => {
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) {
|
||||
const groups: FieldsWidgetGroup[] = view.viewFieldGroups.map((group) => {
|
||||
const fields: FieldsWidgetGroupField[] = (group.viewFields ?? [])
|
||||
.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: 0,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
position: group.position,
|
||||
isVisible: group.isVisible,
|
||||
fields,
|
||||
};
|
||||
});
|
||||
|
||||
return getHiddenFieldsFromGroups(groups);
|
||||
}
|
||||
|
||||
if (isDefined(view) && view.viewFields.length > 0) {
|
||||
let globalIndex = 0;
|
||||
|
||||
return [...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: false,
|
||||
globalIndex: globalIndex++,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [objectMetadataItem, view]);
|
||||
|
||||
return { hiddenFields };
|
||||
};
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
|
||||
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
|
||||
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
|
||||
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
|
||||
import { useFieldsWidgetHiddenFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetHiddenFields';
|
||||
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseFieldsWidgetHiddenFieldsForDisplayParams = {
|
||||
widgetId: string;
|
||||
viewId: string | null;
|
||||
objectNameSingular: string;
|
||||
};
|
||||
|
||||
export const useFieldsWidgetHiddenFieldsForDisplay = ({
|
||||
widgetId,
|
||||
viewId,
|
||||
objectNameSingular,
|
||||
}: UseFieldsWidgetHiddenFieldsForDisplayParams) => {
|
||||
const isPageLayoutInEditMode = useAtomComponentStateValue(
|
||||
isPageLayoutInEditModeComponentState,
|
||||
);
|
||||
|
||||
const fieldsWidgetGroupsDraft = useAtomComponentStateValue(
|
||||
fieldsWidgetGroupsDraftComponentState,
|
||||
);
|
||||
|
||||
const fieldsWidgetUngroupedFieldsDraft = useAtomComponentStateValue(
|
||||
fieldsWidgetUngroupedFieldsDraftComponentState,
|
||||
);
|
||||
|
||||
const fieldsWidgetEditorModeDraft = useAtomComponentStateValue(
|
||||
fieldsWidgetEditorModeDraftComponentState,
|
||||
);
|
||||
|
||||
const { hiddenFields: viewHiddenFields } = useFieldsWidgetHiddenFields({
|
||||
viewId,
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const draftGroupsForWidget = fieldsWidgetGroupsDraft[widgetId];
|
||||
const draftEditorMode = fieldsWidgetEditorModeDraft[widgetId];
|
||||
const ungroupedFieldsForWidget = fieldsWidgetUngroupedFieldsDraft[widgetId];
|
||||
|
||||
const hasDraftGroups =
|
||||
isDefined(draftGroupsForWidget) && draftGroupsForWidget.length > 0;
|
||||
|
||||
const hasDraftUngroupedFields =
|
||||
isDefined(ungroupedFieldsForWidget) && ungroupedFieldsForWidget.length > 0;
|
||||
|
||||
const hiddenFields = useMemo<FieldsWidgetGroupField[]>(() => {
|
||||
if (
|
||||
isPageLayoutInEditMode &&
|
||||
draftEditorMode === 'ungrouped' &&
|
||||
hasDraftUngroupedFields
|
||||
) {
|
||||
let globalIndex = 0;
|
||||
|
||||
return ungroupedFieldsForWidget
|
||||
.filter((field) => !field.isVisible)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((field) => ({
|
||||
...field,
|
||||
globalIndex: globalIndex++,
|
||||
}));
|
||||
}
|
||||
|
||||
if (isPageLayoutInEditMode && hasDraftGroups) {
|
||||
return getHiddenFieldsFromGroups(draftGroupsForWidget);
|
||||
}
|
||||
|
||||
return viewHiddenFields;
|
||||
}, [
|
||||
isPageLayoutInEditMode,
|
||||
draftEditorMode,
|
||||
hasDraftGroups,
|
||||
hasDraftUngroupedFields,
|
||||
draftGroupsForWidget,
|
||||
ungroupedFieldsForWidget,
|
||||
viewHiddenFields,
|
||||
]);
|
||||
|
||||
return { hiddenFields };
|
||||
};
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
|
||||
|
||||
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('getHiddenFieldsFromGroups', () => {
|
||||
it('should return empty array for empty input', () => {
|
||||
const result = getHiddenFieldsFromGroups([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when all fields are visible in visible groups', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
isVisible: true,
|
||||
fields: [
|
||||
makeField({ isVisible: true, viewFieldId: 'vf1' }),
|
||||
makeField({ isVisible: true, viewFieldId: 'vf2' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should collect hidden fields from visible groups', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
isVisible: true,
|
||||
fields: [
|
||||
makeField({ isVisible: true, viewFieldId: 'vf1' }),
|
||||
makeField({ isVisible: false, viewFieldId: 'vf2' }),
|
||||
makeField({ isVisible: true, viewFieldId: 'vf3' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].viewFieldId).toBe('vf2');
|
||||
expect(result[0].isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should collect all fields from hidden groups', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
isVisible: false,
|
||||
fields: [
|
||||
makeField({ isVisible: true, viewFieldId: 'vf1' }),
|
||||
makeField({ isVisible: true, viewFieldId: 'vf2' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].isVisible).toBe(false);
|
||||
expect(result[1].isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should sort groups by position', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g2',
|
||||
position: 1,
|
||||
isVisible: false,
|
||||
fields: [makeField({ viewFieldId: 'vf2' })],
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
position: 0,
|
||||
isVisible: false,
|
||||
fields: [makeField({ viewFieldId: 'vf1' })],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result[0].viewFieldId).toBe('vf1');
|
||||
expect(result[1].viewFieldId).toBe('vf2');
|
||||
});
|
||||
|
||||
it('should sort fields by position within each group', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
isVisible: false,
|
||||
fields: [
|
||||
makeField({ position: 2, viewFieldId: 'vf2' }),
|
||||
makeField({ position: 0, viewFieldId: 'vf0' }),
|
||||
makeField({ position: 1, viewFieldId: 'vf1' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result.map((f) => f.viewFieldId)).toEqual(['vf0', 'vf1', 'vf2']);
|
||||
});
|
||||
|
||||
it('should compute correct globalIndex across groups', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
position: 0,
|
||||
isVisible: false,
|
||||
fields: [
|
||||
makeField({ position: 0, viewFieldId: 'vf1' }),
|
||||
makeField({ position: 1, viewFieldId: 'vf2' }),
|
||||
],
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'g2',
|
||||
position: 1,
|
||||
isVisible: false,
|
||||
fields: [makeField({ position: 0, viewFieldId: 'vf3' })],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result[0].globalIndex).toBe(0);
|
||||
expect(result[1].globalIndex).toBe(1);
|
||||
expect(result[2].globalIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('should not count visible fields in globalIndex', () => {
|
||||
const groups: FieldsWidgetGroup[] = [
|
||||
makeGroup({
|
||||
id: 'g1',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
makeField({ position: 0, isVisible: true, viewFieldId: 'vf1' }),
|
||||
makeField({ position: 1, isVisible: false, viewFieldId: 'vf2' }),
|
||||
],
|
||||
}),
|
||||
makeGroup({
|
||||
id: 'g2',
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
fields: [
|
||||
makeField({ position: 0, isVisible: false, viewFieldId: 'vf3' }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getHiddenFieldsFromGroups(groups);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].globalIndex).toBe(0);
|
||||
expect(result[0].viewFieldId).toBe('vf2');
|
||||
expect(result[1].globalIndex).toBe(1);
|
||||
expect(result[1].viewFieldId).toBe('vf3');
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
type FieldsWidgetGroup,
|
||||
type FieldsWidgetGroupField,
|
||||
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
|
||||
|
||||
export const getHiddenFieldsFromGroups = (
|
||||
groups: FieldsWidgetGroup[],
|
||||
): FieldsWidgetGroupField[] => {
|
||||
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
|
||||
|
||||
let globalIndex = 0;
|
||||
const result: FieldsWidgetGroupField[] = [];
|
||||
|
||||
for (const group of sortedGroups) {
|
||||
const sortedFields = [...group.fields].sort(
|
||||
(a, b) => a.position - b.position,
|
||||
);
|
||||
|
||||
for (const field of sortedFields) {
|
||||
if (field.isVisible && group.isVisible) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
...field,
|
||||
isVisible: false,
|
||||
globalIndex: globalIndex++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+33
-4
@@ -1,17 +1,19 @@
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
||||
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
|
||||
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
|
||||
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
|
||||
import { SidePanelList } from '@/side-panel/components/SidePanelList';
|
||||
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
|
||||
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
|
||||
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
|
||||
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
|
||||
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
|
||||
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { SidePanelPages } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLayoutSidebarRight } from 'twenty-ui/display';
|
||||
import { IconChevronDown, IconLayoutSidebarRight } from 'twenty-ui/display';
|
||||
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -33,6 +35,9 @@ export const SidePanelPageLayoutFieldsSettings = () => {
|
||||
const { pageLayoutId, objectNameSingular } =
|
||||
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
|
||||
|
||||
const { updateCurrentWidgetConfig } =
|
||||
useUpdateCurrentWidgetConfig(pageLayoutId);
|
||||
|
||||
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
|
||||
|
||||
const fieldsConfiguration = widgetInEditMode?.configuration as
|
||||
@@ -59,6 +64,18 @@ export const SidePanelPageLayoutFieldsSettings = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const isShouldAllowUserToSeeHiddenFieldsToggled =
|
||||
fieldsConfiguration?.shouldAllowUserToSeeHiddenFields === true;
|
||||
|
||||
const handleToggleShouldAllowUserToSeeHiddenFields = () => {
|
||||
updateCurrentWidgetConfig({
|
||||
configToUpdate: {
|
||||
shouldAllowUserToSeeHiddenFields:
|
||||
!isShouldAllowUserToSeeHiddenFieldsToggled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectableItemIds = [
|
||||
'layout',
|
||||
'display-more-fields-button',
|
||||
@@ -75,7 +92,7 @@ export const SidePanelPageLayoutFieldsSettings = () => {
|
||||
<StyledContainer>
|
||||
<StyledSidePanelContainer>
|
||||
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
|
||||
<SidePanelGroup heading={t`Customize`}>
|
||||
<SidePanelGroup heading={t`Data and display`}>
|
||||
<SelectableListItem
|
||||
itemId="layout"
|
||||
onEnter={handleNavigateToLayout}
|
||||
@@ -90,6 +107,18 @@ export const SidePanelPageLayoutFieldsSettings = () => {
|
||||
contextualTextPosition="right"
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId="display-more-fields-button"
|
||||
onEnter={handleToggleShouldAllowUserToSeeHiddenFields}
|
||||
>
|
||||
<CommandMenuItemToggle
|
||||
LeftIcon={IconChevronDown}
|
||||
text={t`Display "More fields" button`}
|
||||
id="display-more-fields-button"
|
||||
toggled={isShouldAllowUserToSeeHiddenFieldsToggled}
|
||||
onToggleChange={handleToggleShouldAllowUserToSeeHiddenFields}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</SidePanelGroup>
|
||||
</SidePanelList>
|
||||
</StyledSidePanelContainer>
|
||||
|
||||
@@ -1169,6 +1169,7 @@ type FieldsConfiguration {
|
||||
configurationType: WidgetConfigurationType!
|
||||
viewId: String
|
||||
newFieldDefaultConfiguration: NewFieldDefaultConfiguration
|
||||
shouldAllowUserToSeeHiddenFields: Boolean
|
||||
}
|
||||
|
||||
type FilesConfiguration {
|
||||
|
||||
@@ -888,6 +888,7 @@ export interface FieldsConfiguration {
|
||||
configurationType: WidgetConfigurationType
|
||||
viewId?: Scalars['String']
|
||||
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration
|
||||
shouldAllowUserToSeeHiddenFields?: Scalars['Boolean']
|
||||
__typename: 'FieldsConfiguration'
|
||||
}
|
||||
|
||||
@@ -3752,6 +3753,7 @@ export interface FieldsConfigurationGenqlSelection{
|
||||
configurationType?: boolean | number
|
||||
viewId?: boolean | number
|
||||
newFieldDefaultConfiguration?: NewFieldDefaultConfigurationGenqlSelection
|
||||
shouldAllowUserToSeeHiddenFields?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
@@ -2418,6 +2418,9 @@ export default {
|
||||
"newFieldDefaultConfiguration": [
|
||||
73
|
||||
],
|
||||
"shouldAllowUserToSeeHiddenFields": [
|
||||
6
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
|
||||
+5
@@ -42,4 +42,9 @@ export class FieldsConfigurationDTO implements FieldsConfiguration {
|
||||
@ValidateNested()
|
||||
@Type(() => NewFieldDefaultConfigurationDTO)
|
||||
newFieldDefaultConfiguration: NewFieldDefaultConfigurationDTO | null;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
shouldAllowUserToSeeHiddenFields?: boolean;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ export type FieldsConfiguration = {
|
||||
configurationType: 'FIELDS';
|
||||
viewId?: string | null;
|
||||
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration | null;
|
||||
shouldAllowUserToSeeHiddenFields?: boolean;
|
||||
};
|
||||
|
||||
export type FieldRichTextConfiguration = {
|
||||
|
||||
Reference in New Issue
Block a user