From 5b544809f7b90fd0e10e293b2212b33668265047 Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Wed, 4 Mar 2026 12:45:14 +0100 Subject: [PATCH] Support ungrouped fields + improve edition UX (#18224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Demo https://github.com/user-attachments/assets/59e530ea-1c5b-44be-a012-42551e68221c ## Demo – creating a new group https://github.com/user-attachments/assets/e8511bc3-d586-422c-aca8-b02794a0c84f ## Demo – ungrouped fields https://github.com/user-attachments/assets/6ded4a90-fb08-485e-ad08-086f3a970752 Closes https://github.com/twentyhq/core-team-issues/issues/2232 Closes https://github.com/twentyhq/core-team-issues/issues/2237 Closes https://github.com/twentyhq/core-team-issues/issues/2238 --- ...setDraftPageLayoutToPersistedPageLayout.ts | 47 ++ .../hooks/useSaveFieldsWidgetGroups.ts | 96 ++- .../page-layout/hooks/useSavePageLayout.ts | 9 +- ...eldsWidgetEditorModeDraftComponentState.ts | 11 + ...WidgetEditorModePersistedComponentState.ts | 11 + ...idgetUngroupedFieldsDraftComponentState.ts | 11 + ...tUngroupedFieldsPersistedComponentState.ts | 11 + ...onvertPageLayoutDraftToUpdateInput.test.ts | 80 ++- ...ectDynamicRelationWidgetsFromDraft.test.ts | 45 +- .../convertPageLayoutDraftToUpdateInput.ts | 44 +- ...reInjectDynamicRelationWidgetsFromDraft.ts | 8 +- .../components/FieldsConfigurationEditor.tsx | 64 +- .../FieldsConfigurationGroupDropdown.tsx | 3 +- .../FieldsConfigurationGroupEditor.tsx | 26 +- .../FieldsConfigurationUngroupedEditor.tsx | 94 +++ .../fields/components/FieldsWidget.tsx | 166 +---- .../components/FieldsWidgetFieldItem.tsx | 128 ++++ .../components/FieldsWidgetFieldList.tsx | 71 +++ ...sWidgetGroupsDraftInitializationEffect.tsx | 10 +- .../__stories__/FieldsWidget.stories.tsx | 586 ++++++++++++++++++ .../hooks/useCreateFieldsWidgetEditorGroup.ts | 112 +++- .../hooks/useDeleteFieldsWidgetEditorGroup.ts | 99 ++- .../hooks/useFieldsWidgetEditorGroupsData.ts | 50 +- .../fields/hooks/useFieldsWidgetEditorMode.ts | 24 + .../fields/hooks/useFieldsWidgetGroups.ts | 16 +- .../hooks/useFieldsWidgetGroupsDraft.ts | 28 - .../hooks/useFieldsWidgetGroupsForDisplay.ts | 67 +- .../useInitializeFieldsWidgetGroupsDraft.ts | 84 ++- .../hooks/useMoveUngroupedFieldInDraft.ts | 56 ++ ...seToggleUngroupedFieldVisibilityInDraft.ts | 42 ++ .../fields/types/FieldsWidgetDisplayMode.ts | 1 + .../fields/types/FieldsWidgetEditorMode.ts | 1 + ...tFieldsConfigurationGroupEditDropdownId.ts | 3 + ...ieldsConfigurationGroupRenameDropdownId.ts | 5 + .../widget-card/components/WidgetCard.tsx | 10 +- .../src/modules/views/states/coreViewState.ts | 2 +- 36 files changed, 1839 insertions(+), 282 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModeDraftComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModePersistedComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldItem.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldList.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/components/__stories__/FieldsWidget.stories.tsx create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetDisplayMode.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetEditorMode.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupEditDropdownId.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId.ts diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout.ts b/packages/twenty-front/src/modules/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout.ts index 45b0b383e3..0e8395c1be 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout.ts @@ -1,6 +1,10 @@ import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +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 { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState'; @@ -54,6 +58,29 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ( componentInstanceId, ); + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + componentInstanceId, + ); + + const fieldsWidgetUngroupedFieldsPersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsPersistedComponentState, + componentInstanceId, + ); + + const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState( + fieldsWidgetEditorModeDraftComponentState, + componentInstanceId, + ); + + const fieldsWidgetEditorModePersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetEditorModePersistedComponentState, + componentInstanceId, + ); + const resetDraftPageLayoutToPersistedPageLayout = useCallback(() => { const pageLayoutPersisted = store.get(pageLayoutPersistedState); @@ -83,6 +110,22 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ( fieldsWidgetGroupsPersistedState, ); store.set(fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersisted); + + const fieldsWidgetUngroupedFieldsPersisted = store.get( + fieldsWidgetUngroupedFieldsPersistedState, + ); + store.set( + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetUngroupedFieldsPersisted, + ); + + const fieldsWidgetEditorModePersisted = store.get( + fieldsWidgetEditorModePersistedState, + ); + store.set( + fieldsWidgetEditorModeDraftState, + fieldsWidgetEditorModePersisted, + ); } }, [ pageLayoutDraftState, @@ -90,6 +133,10 @@ export const useResetDraftPageLayoutToPersistedPageLayout = ( pageLayoutCurrentLayoutsState, fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersistedState, + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetUngroupedFieldsPersistedState, + fieldsWidgetEditorModeDraftState, + fieldsWidgetEditorModePersistedState, activeTabId, store, ]); diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts index 7f514c6b56..b44562746f 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts @@ -1,6 +1,10 @@ import { UPSERT_FIELDS_WIDGET } from '@/page-layout/graphql/mutations/upsertFieldsWidget'; +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 { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useRefreshAllCoreViews } from '@/views/hooks/useRefreshAllCoreViews'; import { useMutation } from '@apollo/client'; @@ -50,6 +54,29 @@ export const useSaveFieldsWidgetGroups = ({ pageLayoutId, ); + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetUngroupedFieldsPersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsPersistedComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState( + fieldsWidgetEditorModeDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModePersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetEditorModePersistedComponentState, + pageLayoutId, + ); + const [upsertFieldsWidgetMutation] = useMutation< UpsertFieldsWidgetResult, { input: UpsertFieldsWidgetInput } @@ -62,25 +89,57 @@ export const useSaveFieldsWidgetGroups = ({ const saveFieldsWidgetGroups = useCallback(async () => { const allDraftGroups = store.get(fieldsWidgetGroupsDraftState); const allPersistedGroups = store.get(fieldsWidgetGroupsPersistedState); + const allUngroupedFieldsDraft = store.get( + fieldsWidgetUngroupedFieldsDraftState, + ); + const allEditorModes = store.get(fieldsWidgetEditorModeDraftState); const widgetIds = new Set([ ...Object.keys(allDraftGroups), ...Object.keys(allPersistedGroups), + ...Object.keys(allUngroupedFieldsDraft), ]); for (const widgetId of widgetIds) { - const draftGroups = allDraftGroups[widgetId] ?? []; + const editorMode = allEditorModes[widgetId] ?? 'ungrouped'; - await upsertFieldsWidgetMutation({ - variables: { - input: { - widgetId, - groups: draftGroups.map((group) => ({ - id: group.id, - name: group.name, - position: group.position, - isVisible: group.isVisible, - fields: group.fields.flatMap((field) => { + if (editorMode === 'grouped') { + const draftGroups = allDraftGroups[widgetId] ?? []; + + await upsertFieldsWidgetMutation({ + variables: { + input: { + widgetId, + groups: draftGroups.map((group) => ({ + id: group.id, + name: group.name, + position: group.position, + isVisible: group.isVisible, + fields: group.fields.flatMap((field) => { + if (!isDefined(field.viewFieldId)) { + return []; + } + + return [ + { + viewFieldId: field.viewFieldId, + isVisible: field.isVisible, + position: field.position, + }, + ]; + }), + })), + }, + }, + }); + } else { + const ungroupedFields = allUngroupedFieldsDraft[widgetId] ?? []; + + await upsertFieldsWidgetMutation({ + variables: { + input: { + widgetId, + fields: ungroupedFields.flatMap((field) => { if (!isDefined(field.viewFieldId)) { return []; } @@ -93,13 +152,18 @@ export const useSaveFieldsWidgetGroups = ({ }, ]; }), - })), + }, }, - }, - }); + }); + } } store.set(fieldsWidgetGroupsPersistedState, allDraftGroups); + store.set( + fieldsWidgetUngroupedFieldsPersistedState, + allUngroupedFieldsDraft, + ); + store.set(fieldsWidgetEditorModePersistedState, allEditorModes); await refreshAllCoreViews(); @@ -107,6 +171,10 @@ export const useSaveFieldsWidgetGroups = ({ }, [ fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersistedState, + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetUngroupedFieldsPersistedState, + fieldsWidgetEditorModeDraftState, + fieldsWidgetEditorModePersistedState, upsertFieldsWidgetMutation, refreshAllCoreViews, store, diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSavePageLayout.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSavePageLayout.ts index 34e9d5daf1..d8ea566ddd 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSavePageLayout.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSavePageLayout.ts @@ -56,15 +56,16 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => { result.response.data?.updatePageLayoutWithTabsAndWidgets; if (isDefined(updatedPageLayout)) { - const serverLayout: PageLayout = transformPageLayout(updatedPageLayout); + const persistedLayout: PageLayout = + transformPageLayout(updatedPageLayout); const pageLayoutToPersist = - serverLayout.type === PageLayoutType.RECORD_PAGE + persistedLayout.type === PageLayoutType.RECORD_PAGE ? reInjectDynamicRelationWidgetsFromDraft( - serverLayout, + persistedLayout, pageLayoutDraft, ) - : serverLayout; + : persistedLayout; store.set(pageLayoutPersistedCallbackState, pageLayoutToPersist); store.set( diff --git a/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModeDraftComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModeDraftComponentState.ts new file mode 100644 index 0000000000..680d015aac --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModeDraftComponentState.ts @@ -0,0 +1,11 @@ +import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const fieldsWidgetEditorModeDraftComponentState = + createAtomComponentState>({ + key: 'fieldsWidgetEditorModeDraftComponentState', + defaultValue: {}, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModePersistedComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModePersistedComponentState.ts new file mode 100644 index 0000000000..cc04c8e730 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetEditorModePersistedComponentState.ts @@ -0,0 +1,11 @@ +import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const fieldsWidgetEditorModePersistedComponentState = + createAtomComponentState>({ + key: 'fieldsWidgetEditorModePersistedComponentState', + defaultValue: {}, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState.ts new file mode 100644 index 0000000000..938e4bcdc8 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState.ts @@ -0,0 +1,11 @@ +import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const fieldsWidgetUngroupedFieldsDraftComponentState = + createAtomComponentState>({ + key: 'fieldsWidgetUngroupedFieldsDraftComponentState', + defaultValue: {}, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState.ts new file mode 100644 index 0000000000..fccde3e0f4 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState.ts @@ -0,0 +1,11 @@ +import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext'; + +export const fieldsWidgetUngroupedFieldsPersistedComponentState = + createAtomComponentState>({ + key: 'fieldsWidgetUngroupedFieldsPersistedComponentState', + defaultValue: {}, + componentInstanceContext: PageLayoutComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts index b49de81196..fe44a0e40a 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/convertPageLayoutDraftToUpdateInput.test.ts @@ -2,7 +2,11 @@ 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'; +import { + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetType, +} from '~/generated-metadata/graphql'; const makeWidget = ( overrides: Partial & { id: string }, @@ -134,4 +138,78 @@ describe('convertPageLayoutDraftToUpdateInput', () => { expect(result.tabs[0].widgets).toHaveLength(2); expect(result.tabs[0].widgets.map((w) => w.id)).toEqual(['w1', 'w2']); }); + + it('should produce VERTICAL_LIST position with index from widget.position', () => { + const widget = makeWidget({ + id: 'w1', + position: { + __typename: 'PageLayoutWidgetVerticalListPosition', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: 3, + }, + }); + const draft = makeDraft([makeTab('tab-1', [widget])]); + + const result = convertPageLayoutDraftToUpdateInput(draft); + + expect(result.tabs[0].widgets[0].position).toEqual({ + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: 3, + }); + }); + + it('should fall back to array index for VERTICAL_LIST when position typename is missing', () => { + const widget = makeWidget({ + id: 'w1', + position: { + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + } as PageLayoutWidget['position'], + }); + const draft = makeDraft([ + makeTab('tab-1', [makeWidget({ id: 'w0' }), widget]), + ]); + + const result = convertPageLayoutDraftToUpdateInput(draft); + + expect(result.tabs[0].widgets[1].position).toEqual({ + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index: 1, + }); + }); + + it('should produce CANVAS position without grid fields', () => { + const widget = makeWidget({ + id: 'w1', + position: { + __typename: 'PageLayoutWidgetCanvasPosition', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + }, + }); + const draft = makeDraft([makeTab('tab-1', [widget])]); + + const result = convertPageLayoutDraftToUpdateInput(draft); + + expect(result.tabs[0].widgets[0].position).toEqual({ + layoutMode: PageLayoutTabLayoutMode.CANVAS, + }); + }); + + it('should produce GRID position from gridPosition when position is null', () => { + const widget = makeWidget({ + id: 'w1', + position: null, + gridPosition: { row: 1, column: 2, rowSpan: 3, columnSpan: 4 }, + }); + const draft = makeDraft([makeTab('tab-1', [widget])]); + + const result = convertPageLayoutDraftToUpdateInput(draft); + + expect(result.tabs[0].widgets[0].position).toEqual({ + layoutMode: PageLayoutTabLayoutMode.GRID, + row: 1, + column: 2, + rowSpan: 3, + columnSpan: 4, + }); + }); }); diff --git a/packages/twenty-front/src/modules/page-layout/utils/__tests__/reInjectDynamicRelationWidgetsFromDraft.test.ts b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reInjectDynamicRelationWidgetsFromDraft.test.ts index 0182f5896e..24ac8657b6 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/__tests__/reInjectDynamicRelationWidgetsFromDraft.test.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/__tests__/reInjectDynamicRelationWidgetsFromDraft.test.ts @@ -24,7 +24,7 @@ const makeDynamicRelationWidget = (suffix: string): PageLayoutWidget => title: `Dynamic Relation ${suffix}`, }); -const makeServerLayout = (tabs: PageLayout['tabs']): PageLayout => +const makePersistedLayout = (tabs: PageLayout['tabs']): PageLayout => ({ id: 'layout-1', name: 'Layout', @@ -58,17 +58,20 @@ const makeTab = ( }) as PageLayout['tabs'][number]; describe('reInjectDynamicRelationWidgetsFromDraft', () => { - it('should return server layout unchanged when draft has no dynamic widgets', () => { - const serverLayout = makeServerLayout([ + it('should return persisted layout unchanged when draft has no dynamic widgets', () => { + const persistedLayout = makePersistedLayout([ makeTab('tab-1', [makeWidget({ id: 'w1' })]), ]); const draft = makeDraftLayout([ makeTab('tab-1', [makeWidget({ id: 'w1' })]), ]); - const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft); + const result = reInjectDynamicRelationWidgetsFromDraft( + persistedLayout, + draft, + ); - expect(result).toBe(serverLayout); + expect(result).toBe(persistedLayout); }); it('should inject dynamic widgets after the first FIELDS widget', () => { @@ -82,14 +85,17 @@ describe('reInjectDynamicRelationWidgetsFromDraft', () => { }); const dynamicWidget = makeDynamicRelationWidget('relation-1'); - const serverLayout = makeServerLayout([ + const persistedLayout = makePersistedLayout([ makeTab('tab-1', [fieldsWidget, timelineWidget]), ]); const draft = makeDraftLayout([ makeTab('tab-1', [fieldsWidget, dynamicWidget, timelineWidget]), ]); - const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft); + const result = reInjectDynamicRelationWidgetsFromDraft( + persistedLayout, + draft, + ); expect(result.tabs[0].widgets).toHaveLength(3); expect(result.tabs[0].widgets[0].id).toBe('fields-1'); @@ -106,12 +112,17 @@ describe('reInjectDynamicRelationWidgetsFromDraft', () => { }); const dynamicWidget = makeDynamicRelationWidget('relation-1'); - const serverLayout = makeServerLayout([makeTab('tab-1', [timelineWidget])]); + const persistedLayout = makePersistedLayout([ + makeTab('tab-1', [timelineWidget]), + ]); const draft = makeDraftLayout([ makeTab('tab-1', [timelineWidget, dynamicWidget]), ]); - const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft); + const result = reInjectDynamicRelationWidgetsFromDraft( + persistedLayout, + draft, + ); expect(result.tabs[0].widgets).toHaveLength(2); expect(result.tabs[0].widgets[0].id).toBe('timeline-1'); @@ -131,7 +142,7 @@ describe('reInjectDynamicRelationWidgetsFromDraft', () => { }); const dynamicWidget = makeDynamicRelationWidget('rel-1'); - const serverLayout = makeServerLayout([ + const persistedLayout = makePersistedLayout([ makeTab('tab-1', [fieldsWidget1]), makeTab('tab-2', [fieldsWidget2]), ]); @@ -140,7 +151,10 @@ describe('reInjectDynamicRelationWidgetsFromDraft', () => { makeTab('tab-2', [fieldsWidget2]), ]); - const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft); + const result = reInjectDynamicRelationWidgetsFromDraft( + persistedLayout, + draft, + ); // Tab 1 should have dynamic widget injected expect(result.tabs[0].widgets).toHaveLength(2); @@ -156,12 +170,17 @@ describe('reInjectDynamicRelationWidgetsFromDraft', () => { const dynamicWidget1 = makeDynamicRelationWidget('rel-1'); const dynamicWidget2 = makeDynamicRelationWidget('rel-2'); - const serverLayout = makeServerLayout([makeTab('tab-1', [fieldsWidget])]); + const persistedLayout = makePersistedLayout([ + makeTab('tab-1', [fieldsWidget]), + ]); const draft = makeDraftLayout([ makeTab('tab-1', [fieldsWidget, dynamicWidget1, dynamicWidget2]), ]); - const result = reInjectDynamicRelationWidgetsFromDraft(serverLayout, draft); + const result = reInjectDynamicRelationWidgetsFromDraft( + persistedLayout, + draft, + ); expect(result.tabs[0].widgets).toHaveLength(3); expect(result.tabs[0].widgets[0].id).toBe('fields-1'); diff --git a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts index 720384405e..1a1034a93c 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/convertPageLayoutDraftToUpdateInput.ts @@ -1,10 +1,43 @@ import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWidget'; import { PageLayoutTabLayoutMode, type UpdatePageLayoutWithTabsInput, } from '~/generated-metadata/graphql'; +const buildWidgetPosition = (widget: PageLayoutWidget, widgetIndex: number) => { + const layoutMode = + widget.position?.layoutMode ?? PageLayoutTabLayoutMode.GRID; + + switch (layoutMode) { + case PageLayoutTabLayoutMode.VERTICAL_LIST: { + const index = + widget.position?.__typename === 'PageLayoutWidgetVerticalListPosition' + ? widget.position.index + : widgetIndex; + + return { + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + index, + }; + } + case PageLayoutTabLayoutMode.CANVAS: + return { + layoutMode: PageLayoutTabLayoutMode.CANVAS, + }; + case PageLayoutTabLayoutMode.GRID: + default: + return { + layoutMode: PageLayoutTabLayoutMode.GRID, + row: widget.gridPosition.row, + column: widget.gridPosition.column, + rowSpan: widget.gridPosition.rowSpan, + columnSpan: widget.gridPosition.columnSpan, + }; + } +}; + export const convertPageLayoutDraftToUpdateInput = ( pageLayoutDraft: DraftPageLayout, ): UpdatePageLayoutWithTabsInput => { @@ -18,7 +51,7 @@ export const convertPageLayoutDraftToUpdateInput = ( position: tab.position, widgets: tab.widgets .filter((widget) => !isDynamicRelationWidget(widget)) - .map((widget) => ({ + .map((widget, widgetIndex) => ({ id: widget.id, pageLayoutTabId: widget.pageLayoutTabId, title: widget.title, @@ -30,14 +63,7 @@ export const convertPageLayoutDraftToUpdateInput = ( rowSpan: widget.gridPosition.rowSpan, columnSpan: widget.gridPosition.columnSpan, }, - position: { - layoutMode: - widget.position?.layoutMode ?? PageLayoutTabLayoutMode.GRID, - row: widget.gridPosition.row, - column: widget.gridPosition.column, - rowSpan: widget.gridPosition.rowSpan, - columnSpan: widget.gridPosition.columnSpan, - }, + position: buildWidgetPosition(widget, widgetIndex), configuration: widget.configuration ?? null, })), })), diff --git a/packages/twenty-front/src/modules/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft.ts b/packages/twenty-front/src/modules/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft.ts index 514e272770..5ada10f484 100644 --- a/packages/twenty-front/src/modules/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft.ts +++ b/packages/twenty-front/src/modules/page-layout/utils/reInjectDynamicRelationWidgetsFromDraft.ts @@ -4,7 +4,7 @@ import { isDynamicRelationWidget } from '@/page-layout/utils/isDynamicRelationWi import { WidgetType } from '~/generated-metadata/graphql'; export const reInjectDynamicRelationWidgetsFromDraft = ( - serverLayout: PageLayout, + persistedLayout: PageLayout, previousDraft: DraftPageLayout, ): PageLayout => { const dynamicWidgetsByTabId = new Map( @@ -19,12 +19,12 @@ export const reInjectDynamicRelationWidgetsFromDraft = ( ); if (!hasDynamicWidgets) { - return serverLayout; + return persistedLayout; } return { - ...serverLayout, - tabs: serverLayout.tabs.map((tab) => { + ...persistedLayout, + tabs: persistedLayout.tabs.map((tab) => { const relationWidgets = dynamicWidgetsByTabId.get(tab.id); if (!relationWidgets || relationWidgets.length === 0) { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx index d8918802d3..84752a0db5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationEditor.tsx @@ -7,14 +7,22 @@ import { } from '@hello-pangea/dnd'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; +import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; import { FieldsConfigurationGroupEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor'; +import { FieldsConfigurationUngroupedEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor'; import { useCreateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup'; import { useDeleteFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup'; -import { useFieldsWidgetGroupsDraft } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft'; +import { useFieldsWidgetEditorMode } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode'; import { useMoveFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveFieldInDraft'; +import { useMoveUngroupedFieldInDraft } from '@/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft'; import { useReorderFieldsWidgetEditorGroups } from '@/page-layout/widgets/fields/hooks/useReorderFieldsWidgetEditorGroups'; import { useToggleFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleFieldVisibilityInDraft'; +import { useToggleUngroupedFieldVisibilityInDraft } from '@/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft'; import { useUpdateFieldsWidgetEditorGroup } from '@/page-layout/widgets/fields/hooks/useUpdateFieldsWidgetEditorGroup'; +import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; +import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; @@ -36,11 +44,25 @@ export const FieldsConfigurationEditor = ({ const { t } = useLingui(); const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); - const { draftGroups } = useFieldsWidgetGroupsDraft({ + const { editorMode } = useFieldsWidgetEditorMode({ pageLayoutId, widgetId, }); + const fieldsWidgetGroupsDraft = useAtomComponentStateValue( + fieldsWidgetGroupsDraftComponentState, + pageLayoutId, + ); + + const draftGroups = fieldsWidgetGroupsDraft[widgetId] ?? []; + + const fieldsWidgetUngroupedFieldsDraft = useAtomComponentStateValue( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const ungroupedFields = fieldsWidgetUngroupedFieldsDraft[widgetId] ?? []; + const { createGroup } = useCreateFieldsWidgetEditorGroup({ pageLayoutId, widgetId, @@ -61,6 +83,17 @@ export const FieldsConfigurationEditor = ({ widgetId, }); + const { moveField: moveUngroupedField } = useMoveUngroupedFieldInDraft({ + pageLayoutId, + widgetId, + }); + + const { toggleFieldVisibility: toggleUngroupedFieldVisibility } = + useToggleUngroupedFieldVisibilityInDraft({ + pageLayoutId, + widgetId, + }); + const { updateGroup } = useUpdateFieldsWidgetEditorGroup({ pageLayoutId, widgetId, @@ -71,6 +104,8 @@ export const FieldsConfigurationEditor = ({ widgetId, }); + const { openDropdown } = useOpenDropdown(); + const [renamingGroupValue, setRenamingGroupValue] = useState(''); const handleStartRename = ({ groupName }: { groupName: string }) => { @@ -149,11 +184,28 @@ export const FieldsConfigurationEditor = ({ ); }; - const handleAddGroup = () => { + const handleAddGroup = ({ afterGroupId }: { afterGroupId?: string }) => { const newGroupName = t`New Group`; - createGroup(newGroupName); + const newGroupId = createGroup({ name: newGroupName, afterGroupId }); + + setRenamingGroupValue(newGroupName); + openDropdown({ + dropdownComponentInstanceIdFromProps: + getFieldsConfigurationGroupRenameDropdownId(newGroupId), + }); }; + if (editorMode === 'ungrouped') { + return ( + handleAddGroup({})} + /> + ); + } + const sortedGroups = [...draftGroups].sort((a, b) => a.position - b.position); if (sortedGroups.length === 0) { @@ -182,7 +234,9 @@ export const FieldsConfigurationEditor = ({ objectMetadataItem={objectMetadataItem} draggableProvided={draggableProvided} isDragging={snapshot.isDragging} - onAddGroup={handleAddGroup} + onAddGroup={() => + handleAddGroup({ afterGroupId: group.id }) + } onToggleFieldVisibility={(fieldMetadataId) => toggleFieldVisibility(group.id, fieldMetadataId) } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown.tsx index 9cecb48a61..244f1eee42 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown.tsx @@ -3,6 +3,7 @@ import { IconDotsVertical, IconPencil, IconTrash } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; import { MenuItem } from 'twenty-ui/navigation'; +import { getFieldsConfigurationGroupEditDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupEditDropdownId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -22,7 +23,7 @@ export const FieldsConfigurationGroupDropdown = ({ }: FieldsConfigurationGroupDropdownProps) => { const { t } = useLingui(); - const dropdownId = `fields-configuration-group-edit-${groupId}`; + const dropdownId = getFieldsConfigurationGroupEditDropdownId(groupId); const { closeDropdown } = useCloseDropdown(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx index 4417f785f2..7e817bf32a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationGroupEditor.tsx @@ -1,5 +1,5 @@ -import { styled } from '@linaria/react'; import { Droppable, type DraggableProvided } from '@hello-pangea/dnd'; +import { styled } from '@linaria/react'; import { useLingui } from '@lingui/react/macro'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; @@ -9,6 +9,7 @@ import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/com import { FieldsConfigurationGroupDropdown } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupDropdown'; import { FieldsConfigurationGroupRenameInput } from '@/page-layout/widgets/fields/components/FieldsConfigurationGroupRenameInput'; import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { getFieldsConfigurationGroupRenameDropdownId } from '@/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; @@ -23,6 +24,18 @@ const StyledFieldsDroppable = styled.div` flex-direction: column; `; +const StyledEmptyGroupDropZone = styled.div` + align-items: center; + border: 1px dashed ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${themeCssVariables.font.color.light}; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + justify-content: center; + margin: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; + min-height: ${themeCssVariables.spacing[10]}; +`; + const StyledGroupContainer = styled.div<{ isDragging: boolean }>` background: ${({ isDragging }) => isDragging ? themeCssVariables.background.primary : 'transparent'}; @@ -84,7 +97,9 @@ export const FieldsConfigurationGroupEditor = ({ }: FieldsConfigurationGroupEditorProps) => { const { t } = useLingui(); - const renameDropdownId = `fields-configuration-group-rename-${group.id}`; + const renameDropdownId = getFieldsConfigurationGroupRenameDropdownId( + group.id, + ); const { openDropdown } = useOpenDropdown(); const { closeDropdown } = useCloseDropdown(); @@ -171,6 +186,11 @@ export const FieldsConfigurationGroupEditor = ({ // eslint-disable-next-line react/jsx-props-no-spreading {...droppableProvided.droppableProps} > + {sortedFields.length === 0 && ( + + {t`Drop fields here`} + + )} {sortedFields.map((field, fieldIndex) => { return ( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx new file mode 100644 index 0000000000..d20c066d2b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsConfigurationUngroupedEditor.tsx @@ -0,0 +1,94 @@ +import { DragDropContext, Droppable, type DropResult } from '@hello-pangea/dnd'; + +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; + +import { FieldsConfigurationFieldEditor } from '@/page-layout/widgets/fields/components/FieldsConfigurationFieldEditor'; +import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { IconNewSection } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; + +const StyledFieldsDroppable = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +type FieldsConfigurationUngroupedEditorProps = { + ungroupedFields: FieldsWidgetGroupField[]; + onMoveField: (sourceIndex: number, destinationIndex: number) => void; + onToggleFieldVisibility: (fieldMetadataId: string) => void; + onAddGroup: () => void; +}; + +export const FieldsConfigurationUngroupedEditor = ({ + ungroupedFields, + onMoveField, + onToggleFieldVisibility, + onAddGroup, +}: FieldsConfigurationUngroupedEditorProps) => { + const { t } = useLingui(); + + const sortedFields = [...ungroupedFields].sort( + (a, b) => a.position - b.position, + ); + + const handleDragEnd = (result: DropResult) => { + const { source, destination } = result; + + if (!destination) { + return; + } + + if (source.index === destination.index) { + return; + } + + onMoveField(source.index, destination.index); + }; + + return ( + + + {(provided) => ( + + {sortedFields.map((field, fieldIndex) => ( + { + onToggleFieldVisibility(field.fieldMetadataItem.id); + }} + /> + } + /> + ))} + {provided.placeholder} + + + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx index dd0b04967a..2226aac027 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidget.tsx @@ -1,33 +1,15 @@ -import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; -import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; -import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; -import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly'; import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext'; import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext'; -import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; -import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields'; -import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; -import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; -import { isJunctionRelationForbidden } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationForbidden'; -import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; -import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader'; -import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; -import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; -import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; 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 { 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 { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext'; import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; -import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; import { @@ -38,8 +20,8 @@ import { AnimatedPlaceholderEmptyTitle, EMPTY_PLACEHOLDER_TRANSITION_PROPS, } from 'twenty-ui/layout'; -import { type FieldsConfiguration } from '~/generated-metadata/graphql'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { type FieldsConfiguration } from '~/generated-metadata/graphql'; const StyledContainer = styled.div` box-sizing: border-box; @@ -58,6 +40,11 @@ const StyledPropertyBox = styled.div` padding-bottom: ${themeCssVariables.spacing[3]}; `; +const StyledInlineFieldsPropertyBox = styled(StyledPropertyBox)` + padding-bottom: 0; + padding-top: 0; +`; + type FieldsWidgetProps = { widget: PageLayoutWidget; }; @@ -68,34 +55,13 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => { const instanceId = `fields-${widget.id}-${targetRecord.id}${isInRightDrawer ? '-right-drawer' : ''}`; - const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({ - objectRecordId: targetRecord.id, - }); - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular: targetRecord.targetObjectNameSingular, }); - const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); - const { objectMetadataItems } = useObjectMetadataItems(); - - const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ - objectNameSingular: targetRecord.targetObjectNameSingular, - }); - - const isRecordReadOnly = useIsRecordReadOnly({ - recordId: targetRecord.id, - objectMetadataId: objectMetadataItem.id, - }); - - const setRecordFieldListHoverPosition = useSetAtomComponentState( - recordFieldListHoverPositionComponentState, - instanceId, - ); - const fieldsConfiguration = widget.configuration as FieldsConfiguration; - const { groups } = useFieldsWidgetGroupsForDisplay({ + const { groups, displayMode } = useFieldsWidgetGroupsForDisplay({ widgetId: widget.id, viewId: fieldsConfiguration.viewId ?? null, objectNameSingular: targetRecord.targetObjectNameSingular, @@ -138,103 +104,25 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => { instanceId, }} > - {groups.map((group) => ( - - - {isPrefetchLoading ? ( - - ) : ( - <> - {group.fields.map(({ fieldMetadataItem, globalIndex }) => { - const isActivityTarget = isActivityTargetField( - fieldMetadataItem.name, - targetRecord.targetObjectNameSingular, - ); - - return ( - - setRecordFieldListHoverPosition(globalIndex), - anchorId: `${getRecordFieldInputInstanceId({ - recordId: targetRecord.id, - fieldName: fieldMetadataItem.name, - prefix: instanceId, - })}`, - isForbidden: isJunctionRelationForbidden({ - fieldMetadataItem, - sourceObjectMetadataId: objectMetadataItem.id, - objectMetadataItems, - objectPermissionsByObjectMetadataId, - }), - }} - > - {isActivityTarget ? ( - - ) : ( - - - - )} - - ); - })} - - )} - - - ))} + {displayMode === 'inline' ? ( + + group.fields)} + instanceId={instanceId} + /> + + ) : ( + groups.map((group) => ( + + + + + + )) + )} ; + isRecordReadOnly: boolean; + useUpdateRecord: RecordUpdateHook; + recordLoading: boolean; + instanceId: string; + onMouseEnter: () => void; +}; + +export const FieldsWidgetFieldItem = ({ + fieldMetadataItem, + globalIndex, + recordId, + targetObjectNameSingular, + objectMetadataItem, + objectMetadataItems, + objectPermissionsByObjectMetadataId, + isRecordReadOnly, + useUpdateRecord, + recordLoading, + instanceId, + onMouseEnter, +}: FieldsWidgetFieldItemProps) => { + const isActivityTarget = isActivityTargetField( + fieldMetadataItem.name, + targetObjectNameSingular, + ); + + return ( + + {isActivityTarget ? ( + + ) : ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldList.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldList.tsx new file mode 100644 index 0000000000..b4a4c2a2c9 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetFieldList.tsx @@ -0,0 +1,71 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly'; +import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; +import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader'; +import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions'; +import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; +import { FieldsWidgetFieldItem } from '@/page-layout/widgets/fields/components/FieldsWidgetFieldItem'; +import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; + +type FieldsWidgetFieldListProps = { + fields: FieldsWidgetGroupField[]; + instanceId: string; +}; + +export const FieldsWidgetFieldList = ({ + fields, + instanceId, +}: FieldsWidgetFieldListProps) => { + const targetRecord = useTargetRecord(); + + const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({ + objectRecordId: targetRecord.id, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + const { objectMetadataItems } = useObjectMetadataItems(); + + const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ + objectNameSingular: targetRecord.targetObjectNameSingular, + }); + + const isRecordReadOnly = useIsRecordReadOnly({ + recordId: targetRecord.id, + objectMetadataId: objectMetadataItem.id, + }); + + const setRecordFieldListHoverPosition = useSetAtomComponentState( + recordFieldListHoverPositionComponentState, + instanceId, + ); + + if (isPrefetchLoading) { + return ; + } + + return fields.map(({ fieldMetadataItem, globalIndex }) => ( + setRecordFieldListHoverPosition(globalIndex)} + /> + )); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect.tsx index 6f494c3072..57daf10696 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/FieldsWidgetGroupsDraftInitializationEffect.tsx @@ -15,7 +15,11 @@ export const FieldsWidgetGroupsDraftInitializationEffect = ({ }: FieldsWidgetGroupsDraftInitializationEffectProps) => { const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); - const { groups: serverGroups } = useFieldsWidgetEditorGroupsData({ + const { + groups: persistedGroups, + ungroupedFields, + editorMode, + } = useFieldsWidgetEditorGroupsData({ viewId, objectNameSingular: objectMetadataItem.nameSingular, }); @@ -23,7 +27,9 @@ export const FieldsWidgetGroupsDraftInitializationEffect = ({ useInitializeFieldsWidgetGroupsDraft({ pageLayoutId, widgetId, - serverGroups, + persistedGroups, + persistedUngroupedFields: ungroupedFields, + persistedEditorMode: editorMode, }); return null; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/components/__stories__/FieldsWidget.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/__stories__/FieldsWidget.stories.tsx new file mode 100644 index 0000000000..99d40b27ed --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/components/__stories__/FieldsWidget.stories.tsx @@ -0,0 +1,586 @@ +import { + type ApolloClient, + type NormalizedCacheObject, + useApolloClient, +} from '@apollo/client'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { expect, waitFor, within } from 'storybook/test'; + +import { isAppMetadataReadyState } from '@/metadata-store/states/isAppMetadataReadyState'; +import { ApolloCoreClientContext } from '@/object-metadata/contexts/ApolloCoreClientContext'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { PageLayoutContentProvider } from '@/page-layout/contexts/PageLayoutContentContext'; +import { + PAGE_LAYOUT_TEST_INSTANCE_ID, + PageLayoutTestWrapper, +} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState'; +import { type PageLayout } from '@/page-layout/types/PageLayout'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { FieldsWidget } from '@/page-layout/widgets/fields/components/FieldsWidget'; +import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext'; +import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext'; +import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; +import { coreViewsState } from '@/views/states/coreViewState'; +import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; +import { ComponentDecorator } from 'twenty-ui/testing'; +import { + ViewOpenRecordIn as CoreViewOpenRecordIn, + ViewType as CoreViewType, + ViewVisibility as CoreViewVisibility, + PageLayoutTabLayoutMode, + PageLayoutType, + WidgetConfigurationType, + WidgetType, +} from '~/generated-metadata/graphql'; +import { ChipGeneratorsDecorator } from '~/testing/decorators/ChipGeneratorsDecorator'; +import { FileUploadDecorator } from '~/testing/decorators/FileUploadDecorator'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; +import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems'; +import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow'; +import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow'; + +const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow( + CoreObjectNameSingular.Company, +); + +const nameField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'name', +}); + +const addressField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'address', +}); + +const employeesField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'employees', +}); + +const annualRecurringRevenueField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'annualRecurringRevenue', +}); + +const linkedinField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'linkedinLink', +}); + +const idealCustomerProfileField = getMockFieldMetadataItemOrThrow({ + objectMetadataItem: companyObjectMetadataItem, + fieldName: 'idealCustomerProfile', +}); + +const TEST_RECORD_ID = 'test-fields-widget-record-123'; +const FIELDS_VIEW_ID = 'test-fields-view-001'; +const TAB_ID_OVERVIEW = 'tab-overview'; + +const mockCompanyRecord: ObjectRecord = { + __typename: 'Company', + id: TEST_RECORD_ID, + name: 'Acme Corporation', + address: { + addressStreet1: '123 Business St', + addressStreet2: null, + addressCity: 'San Francisco', + addressState: 'CA', + addressPostcode: '94102', + addressCountry: 'United States', + addressLat: null, + addressLng: null, + }, + employees: 250, + linkedinLink: { + primaryLinkUrl: 'https://linkedin.com/company/acme', + primaryLinkLabel: null, + secondaryLinks: null, + }, + idealCustomerProfile: true, + annualRecurringRevenue: { + __typename: 'Currency', + amountMicros: 5000000000000, + currencyCode: 'USD', + }, +}; + +const setRecordInStore = (recordId: string, record: ObjectRecord) => { + jotaiStore.set(recordStoreFamilyState.atomFamily(recordId), record); +}; + +const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], +}); + +const CoreClientProviderWrapper = ({ + children, +}: { + children: React.ReactNode; +}) => { + const apolloClient = useApolloClient() as ApolloClient; + + return ( + + {children} + + ); +}; + +const createPageLayoutWithWidget = ( + widget: PageLayoutWidget, + objectMetadataId: string, +): PageLayout => ({ + id: PAGE_LAYOUT_TEST_INSTANCE_ID, + name: 'Mock Page Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId, + tabs: [ + { + __typename: 'PageLayoutTab' as const, + applicationId: '', + id: TAB_ID_OVERVIEW, + title: 'Overview', + position: 0, + pageLayoutId: PAGE_LAYOUT_TEST_INSTANCE_ID, + widgets: [widget], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, + }, + ], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, +}); + +const createFieldsWidget = (viewId: string | null): PageLayoutWidget => ({ + __typename: 'PageLayoutWidget', + id: 'widget-fields', + pageLayoutTabId: TAB_ID_OVERVIEW, + type: WidgetType.FIELDS, + title: 'Fields', + objectMetadataId: companyObjectMetadataItem.id, + gridPosition: { + __typename: 'GridPosition', + row: 0, + column: 0, + rowSpan: 4, + columnSpan: 4, + }, + configuration: { + __typename: 'FieldsConfiguration', + configurationType: WidgetConfigurationType.FIELDS, + viewId, + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + deletedAt: null, +}); + +const createCoreView = ( + overrides: Partial = {}, +): CoreViewWithRelations => ({ + id: FIELDS_VIEW_ID, + name: 'Company Fields', + objectMetadataId: companyObjectMetadataItem.id, + type: CoreViewType.FIELDS_WIDGET, + icon: 'IconList', + key: null, + shouldHideEmptyGroups: false, + position: 0, + isCompact: false, + openRecordIn: CoreViewOpenRecordIn.SIDE_PANEL, + viewFields: [], + viewGroups: [], + viewFilters: [], + viewSorts: [], + visibility: CoreViewVisibility.WORKSPACE, + createdByUserWorkspaceId: null, + __typename: 'CoreView', + ...overrides, +}); + +const meta: Meta = { + title: 'Modules/PageLayout/Widgets/FieldsWidget', + component: FieldsWidget, + decorators: [ + ComponentDecorator, + ChipGeneratorsDecorator, + FileUploadDecorator, + (Story) => ( + + + + ), + ], + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithViewFieldGroups: Story = { + render: () => { + const coreView = createCoreView({ + viewFieldGroups: [ + { + id: 'group-contact-info', + name: 'Contact Info', + position: 0, + isVisible: true, + viewId: FIELDS_VIEW_ID, + viewFields: [ + { + id: 'vf-name', + fieldMetadataId: nameField.id, + position: 0, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-address', + fieldMetadataId: addressField.id, + position: 1, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-linkedin', + fieldMetadataId: linkedinField.id, + position: 2, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + ], + }, + { + id: 'group-business', + name: 'Business', + position: 1, + isVisible: true, + viewId: FIELDS_VIEW_ID, + viewFields: [ + { + id: 'vf-employees', + fieldMetadataId: employeesField.id, + position: 0, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-arr', + fieldMetadataId: annualRecurringRevenueField.id, + position: 1, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-icp', + fieldMetadataId: idealCustomerProfileField.id, + position: 2, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + ], + }, + ], + }); + + const widget = createFieldsWidget(FIELDS_VIEW_ID); + + const pageLayoutData = createPageLayoutWithWidget( + widget, + companyObjectMetadataItem.id, + ); + + jotaiStore.set( + objectMetadataItemsState.atom, + generatedMockObjectMetadataItems, + ); + jotaiStore.set(isAppMetadataReadyState.atom, true); + jotaiStore.set(coreViewsState.atom, [coreView]); + jotaiStore.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + jotaiStore.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + setRecordInStore(TEST_RECORD_ID, mockCompanyRecord); + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const contactInfoHeader = await canvas.findByText('Contact Info'); + expect(contactInfoHeader).toBeVisible(); + + const businessHeader = await canvas.findByText('Business'); + expect(businessHeader).toBeVisible(); + + const companyName = await canvas.findByText('Acme Corporation'); + expect(companyName).toBeVisible(); + }, +}; + +export const WithInlineViewFields: Story = { + render: () => { + const coreView = createCoreView({ + viewFields: [ + { + id: 'vf-name', + fieldMetadataId: nameField.id, + position: 0, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-employees', + fieldMetadataId: employeesField.id, + position: 1, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + { + id: 'vf-address', + fieldMetadataId: addressField.id, + position: 2, + isVisible: true, + size: 200, + aggregateOperation: null, + viewId: FIELDS_VIEW_ID, + }, + ], + }); + + const widget = createFieldsWidget(FIELDS_VIEW_ID); + + const pageLayoutData = createPageLayoutWithWidget( + widget, + companyObjectMetadataItem.id, + ); + + jotaiStore.set( + objectMetadataItemsState.atom, + generatedMockObjectMetadataItems, + ); + jotaiStore.set(isAppMetadataReadyState.atom, true); + jotaiStore.set(coreViewsState.atom, [coreView]); + jotaiStore.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + jotaiStore.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + setRecordInStore(TEST_RECORD_ID, mockCompanyRecord); + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const companyName = await canvas.findByText('Acme Corporation'); + expect(companyName).toBeVisible(); + + const contactInfoHeader = canvas.queryByText('Contact Info'); + expect(contactInfoHeader).toBeNull(); + + const generalHeader = canvas.queryByText('General'); + expect(generalHeader).toBeNull(); + }, +}; + +export const Empty: Story = { + render: () => { + const coreView = createCoreView({ + viewFieldGroups: [ + { + id: 'group-empty', + name: 'Empty Group', + position: 0, + isVisible: false, + viewId: FIELDS_VIEW_ID, + viewFields: [], + }, + ], + }); + + const widget = createFieldsWidget(FIELDS_VIEW_ID); + + const pageLayoutData = createPageLayoutWithWidget( + widget, + companyObjectMetadataItem.id, + ); + + jotaiStore.set( + objectMetadataItemsState.atom, + generatedMockObjectMetadataItems, + ); + jotaiStore.set(isAppMetadataReadyState.atom, true); + jotaiStore.set(coreViewsState.atom, [coreView]); + jotaiStore.set( + pageLayoutPersistedComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + jotaiStore.set( + pageLayoutDraftComponentState.atomFamily({ + instanceId: PAGE_LAYOUT_TEST_INSTANCE_ID, + }), + pageLayoutData, + ); + setRecordInStore(TEST_RECORD_ID, mockCompanyRecord); + + return ( +
+ + + + + + + + + + + + + +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor(() => { + expect(canvas.getByText('No fields to display')).toBeVisible(); + }); + + await waitFor(() => { + expect( + canvas.getByText('Configure this widget to display fields'), + ).toBeVisible(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup.ts index f8c0655187..baa65edb19 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useCreateFieldsWidgetEditorGroup.ts @@ -1,4 +1,6 @@ +import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState'; import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; +import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; @@ -18,33 +20,107 @@ export const useCreateFieldsWidgetEditorGroup = ({ pageLayoutId, ); + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState( + fieldsWidgetEditorModeDraftComponentState, + pageLayoutId, + ); + const store = useStore(); const createGroup = useCallback( - (name: string) => { - const allDraftGroups = store.get(fieldsWidgetGroupsDraftState); + ({ name, afterGroupId }: { name: string; afterGroupId?: string }) => { + const allEditorModes = store.get(fieldsWidgetEditorModeDraftState); + + const currentEditorMode = allEditorModes[widgetId] ?? 'ungrouped'; - const currentGroups = allDraftGroups[widgetId] ?? []; - const maxPosition = Math.max(...currentGroups.map((g) => g.position), -1); const newId = v4(); - store.set(fieldsWidgetGroupsDraftState, (prev) => ({ - ...prev, - [widgetId]: [ - ...(prev[widgetId] ?? []), - { - id: newId, - name, - position: maxPosition + 1, - isVisible: true, - fields: [], - }, - ], - })); + if (currentEditorMode === 'ungrouped') { + const allUngroupedFields = store.get( + fieldsWidgetUngroupedFieldsDraftState, + ); + + const ungroupedFields = allUngroupedFields[widgetId] ?? []; + + store.set(fieldsWidgetGroupsDraftState, (prev) => ({ + ...prev, + [widgetId]: [ + { + id: newId, + name, + position: 0, + isVisible: true, + fields: ungroupedFields.map((field, index) => ({ + ...field, + position: index, + globalIndex: index, + })), + }, + ], + })); + + store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => ({ + ...prev, + [widgetId]: [], + })); + + store.set(fieldsWidgetEditorModeDraftState, (prev) => ({ + ...prev, + [widgetId]: 'grouped' as const, + })); + } else { + const allDraftGroups = store.get(fieldsWidgetGroupsDraftState); + + const currentGroups = allDraftGroups[widgetId] ?? []; + + const afterGroup = afterGroupId + ? currentGroups.find((g) => g.id === afterGroupId) + : undefined; + + const newPosition = + afterGroup !== undefined + ? afterGroup.position + 1 + : Math.max(...currentGroups.map((g) => g.position), -1) + 1; + + const shiftedGroups = + afterGroup !== undefined + ? currentGroups.map((g) => + g.position >= newPosition + ? { ...g, position: g.position + 1 } + : g, + ) + : currentGroups; + + store.set(fieldsWidgetGroupsDraftState, (prev) => ({ + ...prev, + [widgetId]: [ + ...shiftedGroups, + { + id: newId, + name, + position: newPosition, + isVisible: true, + fields: [], + }, + ], + })); + } return newId; }, - [fieldsWidgetGroupsDraftState, widgetId, store], + [ + fieldsWidgetGroupsDraftState, + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetEditorModeDraftState, + widgetId, + store, + ], ); return { createGroup }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup.ts index 56b3a4cb53..7e6dd14fd3 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useDeleteFieldsWidgetEditorGroup.ts @@ -1,4 +1,6 @@ +import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState'; import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; +import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback } from 'react'; @@ -17,18 +19,107 @@ export const useDeleteFieldsWidgetEditorGroup = ({ pageLayoutId, ); + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState( + fieldsWidgetEditorModeDraftComponentState, + pageLayoutId, + ); + const store = useStore(); const deleteGroup = useCallback( (groupId: string) => { + const allDraftGroups = store.get(fieldsWidgetGroupsDraftState); + + const currentGroups = allDraftGroups[widgetId] ?? []; + + const deletedGroup = currentGroups.find((group) => group.id === groupId); + + if (!deletedGroup) { + return; + } + + const deletedGroupFields = deletedGroup.fields; + const remainingGroups = currentGroups.filter( + (group) => group.id !== groupId, + ); + + if (remainingGroups.length === 0) { + store.set(fieldsWidgetGroupsDraftState, (prev) => ({ + ...prev, + [widgetId]: [], + })); + + store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => ({ + ...prev, + [widgetId]: deletedGroupFields.map((field, index) => ({ + ...field, + position: index, + globalIndex: index, + })), + })); + + store.set(fieldsWidgetEditorModeDraftState, (prev) => ({ + ...prev, + [widgetId]: 'ungrouped' as const, + })); + + return; + } + + const sortedRemaining = [...remainingGroups].sort( + (a, b) => a.position - b.position, + ); + + const deletedGroupPosition = deletedGroup.position; + + const nextGroup = sortedRemaining.find( + (group) => group.position > deletedGroupPosition, + ); + + const targetGroup = + nextGroup ?? sortedRemaining[sortedRemaining.length - 1]; + store.set(fieldsWidgetGroupsDraftState, (prev) => ({ ...prev, - [widgetId]: (prev[widgetId] ?? []).filter( - (group) => group.id !== groupId, - ), + [widgetId]: remainingGroups.map((group) => { + if (group.id !== targetGroup.id) { + return group; + } + + const existingFields = [...group.fields].sort( + (a, b) => a.position - b.position, + ); + + const maxPosition = + existingFields.length > 0 + ? Math.max(...existingFields.map((f) => f.position)) + : -1; + + const appendedFields = deletedGroupFields.map((field, index) => ({ + ...field, + position: maxPosition + 1 + index, + })); + + return { + ...group, + fields: [...existingFields, ...appendedFields], + }; + }), })); }, - [fieldsWidgetGroupsDraftState, widgetId, store], + [ + fieldsWidgetGroupsDraftState, + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetEditorModeDraftState, + widgetId, + store, + ], ); return { deleteGroup }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts index e593372140..479a26938e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorGroupsData.ts @@ -1,31 +1,45 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; +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'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; type UseFieldsWidgetEditorGroupsDataParams = { viewId: string | null; objectNameSingular: string; }; +type UseFieldsWidgetEditorGroupsDataResult = { + groups: FieldsWidgetGroup[]; + ungroupedFields: FieldsWidgetGroupField[]; + editorMode: FieldsWidgetEditorMode; + isFromView: boolean; +}; + export const useFieldsWidgetEditorGroupsData = ({ viewId, objectNameSingular, -}: UseFieldsWidgetEditorGroupsDataParams) => { - const { t } = useLingui(); +}: UseFieldsWidgetEditorGroupsDataParams): UseFieldsWidgetEditorGroupsDataResult => { const { view } = useGetViewById(viewId); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const groups = useMemo(() => { + const result = useMemo< + Pick< + UseFieldsWidgetEditorGroupsDataResult, + 'groups' | 'ungroupedFields' | 'editorMode' + > + >(() => { if (!isDefined(objectMetadataItem)) { - return []; + return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' }; } - if (isDefined(view) && isDefined(view.viewFieldGroups)) { + if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) { const viewFieldGroups = view.viewFieldGroups; const sortedGroups = [...viewFieldGroups].sort( @@ -34,7 +48,7 @@ export const useFieldsWidgetEditorGroupsData = ({ let globalIndex = 0; - return sortedGroups.map((group) => { + const groups = sortedGroups.map((group) => { const groupFields = [...(group.viewFields ?? [])].sort( (a, b) => a.position - b.position, ); @@ -67,6 +81,8 @@ export const useFieldsWidgetEditorGroupsData = ({ fields, }; }); + + return { groups, ungroupedFields: [], editorMode: 'grouped' }; } if (isDefined(view) && view.viewFields.length > 0) { @@ -94,23 +110,15 @@ export const useFieldsWidgetEditorGroupsData = ({ .filter(isDefined); if (fields.length > 0) { - return [ - { - id: `${view.id}-group-general`, - name: t`General`, - position: 0, - isVisible: true, - fields, - }, - ]; + return { groups: [], ungroupedFields: fields, editorMode: 'ungrouped' }; } } - return []; - }, [objectMetadataItem, t, view]); + return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' }; + }, [objectMetadataItem, view]); return { - groups, + ...result, isFromView: isDefined(view) && (isDefined(view.viewFieldGroups) || view.viewFields.length > 0), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode.ts new file mode 100644 index 0000000000..aa7981c555 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetEditorMode.ts @@ -0,0 +1,24 @@ +import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState'; +import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +type UseFieldsWidgetEditorModeParams = { + pageLayoutId: string; + widgetId: string; +}; + +export const useFieldsWidgetEditorMode = ({ + pageLayoutId, + widgetId, +}: UseFieldsWidgetEditorModeParams): { + editorMode: FieldsWidgetEditorMode; +} => { + const fieldsWidgetEditorModeDraft = useAtomComponentStateValue( + fieldsWidgetEditorModeDraftComponentState, + pageLayoutId, + ); + + const editorMode = fieldsWidgetEditorModeDraft[widgetId]; + + return { editorMode }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroups.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroups.ts index b40f3ae51c..3ffdd9847c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroups.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroups.ts @@ -1,5 +1,6 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useFieldsWidgetFieldMetadataItems } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFieldMetadataItems'; +import { type FieldsWidgetDisplayMode } from '@/page-layout/widgets/fields/types/FieldsWidgetDisplayMode'; import { type FieldsWidgetGroup, type FieldsWidgetGroupField, @@ -7,7 +8,7 @@ import { import { useGetViewById } from '@/views/hooks/useGetViewById'; import { useLingui } from '@lingui/react/macro'; import { useMemo } from 'react'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; type UseFieldsWidgetGroupsParams = { viewId: string | null; @@ -33,8 +34,7 @@ export const useFieldsWidgetGroups = ({ return []; } - // Views are seeded for object - if (isDefined(view) && isDefined(view.viewFieldGroups)) { + if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) { const sortedGroups = view.viewFieldGroups.toSorted( (a, b) => a.position - b.position, ); @@ -192,10 +192,18 @@ export const useFieldsWidgetGroups = ({ inlineFieldMetadataItems, ]); + const displayMode: FieldsWidgetDisplayMode = + isDefined(view) && + !isNonEmptyArray(view.viewFieldGroups) && + view.viewFields.length > 0 + ? 'inline' + : 'grouped'; + return { groups, + displayMode, isFromView: isDefined(view) && - (isDefined(view.viewFieldGroups) || view.viewFields.length > 0), + (isNonEmptyArray(view.viewFieldGroups) || view.viewFields.length > 0), }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft.ts deleted file mode 100644 index ef79568e47..0000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsDraft.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState'; -import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { useMemo } from 'react'; - -type UseFieldsWidgetGroupsDraftParams = { - pageLayoutId: string; - widgetId: string; -}; - -export const useFieldsWidgetGroupsDraft = ({ - pageLayoutId, - widgetId, -}: UseFieldsWidgetGroupsDraftParams): { - draftGroups: FieldsWidgetGroup[]; -} => { - const fieldsWidgetGroupsDraft = useAtomComponentStateValue( - fieldsWidgetGroupsDraftComponentState, - pageLayoutId, - ); - - const draftGroups = useMemo( - () => fieldsWidgetGroupsDraft[widgetId] ?? [], - [fieldsWidgetGroupsDraft, widgetId], - ); - - return { draftGroups }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay.ts index e94a46649d..d9513e9250 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay.ts @@ -1,9 +1,13 @@ +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 { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups'; +import { type FieldsWidgetDisplayMode } from '@/page-layout/widgets/fields/types/FieldsWidgetDisplayMode'; import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { filterDraftGroupsForDisplay } from '@/page-layout/widgets/fields/utils/filterDraftGroupsForDisplay'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useLingui } from '@lingui/react/macro'; import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -18,6 +22,8 @@ export const useFieldsWidgetGroupsForDisplay = ({ viewId, objectNameSingular, }: UseFieldsWidgetGroupsForDisplayParams) => { + const { t } = useLingui(); + const isPageLayoutInEditMode = useAtomComponentStateValue( isPageLayoutInEditModeComponentState, ); @@ -26,16 +32,58 @@ export const useFieldsWidgetGroupsForDisplay = ({ fieldsWidgetGroupsDraftComponentState, ); + const fieldsWidgetUngroupedFieldsDraft = useAtomComponentStateValue( + fieldsWidgetUngroupedFieldsDraftComponentState, + ); + + const fieldsWidgetEditorModeDraft = useAtomComponentStateValue( + fieldsWidgetEditorModeDraftComponentState, + ); + const viewGroups = useFieldsWidgetGroups({ 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 groups = useMemo(() => { + if ( + isPageLayoutInEditMode && + draftEditorMode === 'ungrouped' && + hasDraftUngroupedFields + ) { + const visibleFields = ungroupedFieldsForWidget + .filter((field) => field.isVisible) + .sort((a, b) => a.position - b.position) + .map((field, index) => ({ + ...field, + globalIndex: index, + })); + + if (visibleFields.length === 0) { + return []; + } + + return [ + { + id: `${widgetId}-ungrouped-display`, + name: t`General`, + position: 0, + isVisible: true, + fields: visibleFields, + }, + ]; + } + if (isPageLayoutInEditMode && hasDraftGroups) { return filterDraftGroupsForDisplay(draftGroupsForWidget); } @@ -43,10 +91,27 @@ export const useFieldsWidgetGroupsForDisplay = ({ return viewGroups.groups; }, [ isPageLayoutInEditMode, + draftEditorMode, hasDraftGroups, + hasDraftUngroupedFields, draftGroupsForWidget, + ungroupedFieldsForWidget, viewGroups.groups, + widgetId, + t, ]); - return { groups }; + const displayMode: FieldsWidgetDisplayMode = (() => { + if (isPageLayoutInEditMode) { + if (draftEditorMode === 'ungrouped' && hasDraftUngroupedFields) { + return 'inline'; + } + if (hasDraftGroups) { + return 'grouped'; + } + } + return viewGroups.displayMode; + })(); + + return { groups, displayMode }; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useInitializeFieldsWidgetGroupsDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useInitializeFieldsWidgetGroupsDraft.ts index 4bf9c2db55..cf979e419a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useInitializeFieldsWidgetGroupsDraft.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useInitializeFieldsWidgetGroupsDraft.ts @@ -1,7 +1,15 @@ +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 { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; +import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode'; +import { + type FieldsWidgetGroup, + type FieldsWidgetGroupField, +} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useStore } from 'jotai'; import { useCallback, useEffect } from 'react'; @@ -9,13 +17,17 @@ import { useCallback, useEffect } from 'react'; type UseInitializeFieldsWidgetGroupsDraftParams = { pageLayoutId: string; widgetId: string; - serverGroups: FieldsWidgetGroup[]; + persistedGroups: FieldsWidgetGroup[]; + persistedUngroupedFields: FieldsWidgetGroupField[]; + persistedEditorMode: FieldsWidgetEditorMode; }; export const useInitializeFieldsWidgetGroupsDraft = ({ pageLayoutId, widgetId, - serverGroups, + persistedGroups, + persistedUngroupedFields, + persistedEditorMode, }: UseInitializeFieldsWidgetGroupsDraftParams) => { const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState( fieldsWidgetGroupsDraftComponentState, @@ -27,6 +39,29 @@ export const useInitializeFieldsWidgetGroupsDraft = ({ pageLayoutId, ); + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetUngroupedFieldsPersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsPersistedComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState( + fieldsWidgetEditorModeDraftComponentState, + pageLayoutId, + ); + + const fieldsWidgetEditorModePersistedState = + useAtomComponentStateCallbackState( + fieldsWidgetEditorModePersistedComponentState, + pageLayoutId, + ); + const hasInitializedFieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState( hasInitializedFieldsWidgetGroupsDraftComponentState, @@ -51,12 +86,36 @@ export const useInitializeFieldsWidgetGroupsDraft = ({ if (!hasDraftForWidget) { store.set(fieldsWidgetGroupsDraftState, (prev) => ({ ...prev, - [widgetId]: serverGroups, + [widgetId]: persistedGroups, })); store.set(fieldsWidgetGroupsPersistedState, (prev) => ({ ...prev, - [widgetId]: serverGroups, + [widgetId]: persistedGroups, + })); + + store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => ({ + ...prev, + [widgetId]: persistedUngroupedFields, + })); + + store.set(fieldsWidgetUngroupedFieldsPersistedState, (prev) => ({ + ...prev, + [widgetId]: persistedUngroupedFields, + })); + } + + const currentEditorModes = store.get(fieldsWidgetEditorModeDraftState); + + if (!(widgetId in currentEditorModes)) { + store.set(fieldsWidgetEditorModeDraftState, (prev) => ({ + ...prev, + [widgetId]: persistedEditorMode, + })); + + store.set(fieldsWidgetEditorModePersistedState, (prev) => ({ + ...prev, + [widgetId]: persistedEditorMode, })); } @@ -68,14 +127,23 @@ export const useInitializeFieldsWidgetGroupsDraft = ({ hasInitializedFieldsWidgetGroupsDraftState, fieldsWidgetGroupsDraftState, fieldsWidgetGroupsPersistedState, + fieldsWidgetUngroupedFieldsDraftState, + fieldsWidgetUngroupedFieldsPersistedState, + fieldsWidgetEditorModeDraftState, + fieldsWidgetEditorModePersistedState, widgetId, - serverGroups, + persistedGroups, + persistedUngroupedFields, + persistedEditorMode, store, ]); useEffect(() => { - if (serverGroups.length > 0) { + const hasData = + persistedGroups.length > 0 || persistedUngroupedFields.length > 0; + + if (hasData) { initializeDraft(); } - }, [serverGroups, initializeDraft]); + }, [persistedGroups, persistedUngroupedFields, initializeDraft]); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft.ts new file mode 100644 index 0000000000..c5c122aa80 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useMoveUngroupedFieldInDraft.ts @@ -0,0 +1,56 @@ +import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; + +type UseMoveUngroupedFieldInDraftParams = { + pageLayoutId: string; + widgetId: string; +}; + +export const useMoveUngroupedFieldInDraft = ({ + pageLayoutId, + widgetId, +}: UseMoveUngroupedFieldInDraftParams) => { + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const moveField = useCallback( + (sourceIndex: number, destinationIndex: number) => { + store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => { + const currentFields = prev[widgetId] ?? []; + + const sortedFields = [...currentFields].sort( + (a, b) => a.position - b.position, + ); + + const [movedField] = sortedFields.splice(sourceIndex, 1); + + if (!movedField) { + return prev; + } + + sortedFields.splice(destinationIndex, 0, movedField); + + const updatedFields = sortedFields.map((field, index) => ({ + ...field, + position: index, + globalIndex: index, + })); + + return { + ...prev, + [widgetId]: updatedFields, + }; + }); + }, + [fieldsWidgetUngroupedFieldsDraftState, widgetId, store], + ); + + return { moveField }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft.ts new file mode 100644 index 0000000000..6e0556c3c6 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/hooks/useToggleUngroupedFieldVisibilityInDraft.ts @@ -0,0 +1,42 @@ +import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { useStore } from 'jotai'; +import { useCallback } from 'react'; + +type UseToggleUngroupedFieldVisibilityInDraftParams = { + pageLayoutId: string; + widgetId: string; +}; + +export const useToggleUngroupedFieldVisibilityInDraft = ({ + pageLayoutId, + widgetId, +}: UseToggleUngroupedFieldVisibilityInDraftParams) => { + const fieldsWidgetUngroupedFieldsDraftState = + useAtomComponentStateCallbackState( + fieldsWidgetUngroupedFieldsDraftComponentState, + pageLayoutId, + ); + + const store = useStore(); + + const toggleFieldVisibility = useCallback( + (fieldMetadataId: string) => { + store.set(fieldsWidgetUngroupedFieldsDraftState, (prev) => { + const currentFields = prev[widgetId] ?? []; + + return { + ...prev, + [widgetId]: currentFields.map((field) => + field.fieldMetadataItem.id === fieldMetadataId + ? { ...field, isVisible: !field.isVisible } + : field, + ), + }; + }); + }, + [fieldsWidgetUngroupedFieldsDraftState, widgetId, store], + ); + + return { toggleFieldVisibility }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetDisplayMode.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetDisplayMode.ts new file mode 100644 index 0000000000..03cd460ab5 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetDisplayMode.ts @@ -0,0 +1 @@ +export type FieldsWidgetDisplayMode = 'grouped' | 'inline'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetEditorMode.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetEditorMode.ts new file mode 100644 index 0000000000..5d033aec99 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/types/FieldsWidgetEditorMode.ts @@ -0,0 +1 @@ +export type FieldsWidgetEditorMode = 'grouped' | 'ungrouped'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupEditDropdownId.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupEditDropdownId.ts new file mode 100644 index 0000000000..f81453e6d7 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupEditDropdownId.ts @@ -0,0 +1,3 @@ +export const getFieldsConfigurationGroupEditDropdownId = (groupId: string) => { + return `fields-configuration-group-edit-${groupId}`; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId.ts b/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId.ts new file mode 100644 index 0000000000..248c253253 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/fields/utils/getFieldsConfigurationGroupRenameDropdownId.ts @@ -0,0 +1,5 @@ +export const getFieldsConfigurationGroupRenameDropdownId = ( + groupId: string, +) => { + return `fields-configuration-group-rename-${groupId}`; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx index ed7f516ac2..4441a286fe 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx @@ -1,7 +1,7 @@ import { styled } from '@linaria/react'; -import { type WidgetCardVariant } from '~/modules/page-layout/widgets/types/WidgetCardVariant'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; import { isDefined } from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { type WidgetCardVariant } from '~/modules/page-layout/widgets/types/WidgetCardVariant'; type WidgetCardStyledProps = { variant: WidgetCardVariant; @@ -129,6 +129,8 @@ export type WidgetCardProps = { children?: React.ReactNode; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; + 'data-testid'?: string; + 'data-widget-id'?: string; }; export const WidgetCard = ({ @@ -144,6 +146,8 @@ export const WidgetCard = ({ children, onMouseEnter, onMouseLeave, + 'data-testid': dataTestId, + 'data-widget-id': dataWidgetId, }: WidgetCardProps) => { return ( {children} diff --git a/packages/twenty-front/src/modules/views/states/coreViewState.ts b/packages/twenty-front/src/modules/views/states/coreViewState.ts index ac17bc3023..eb74ade888 100644 --- a/packages/twenty-front/src/modules/views/states/coreViewState.ts +++ b/packages/twenty-front/src/modules/views/states/coreViewState.ts @@ -1,5 +1,5 @@ -import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations'; export const coreViewsState = createAtomState({ key: 'coreViewsState',