From 19b56c5d3a1921d54f53e14aa757ba5522d7d7a4 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:39:52 +0530 Subject: [PATCH] [FRONT COMPONENTS] Frontend component widget creation (#17653) closes https://github.com/twentyhq/core-team-issues/issues/2182 https://github.com/user-attachments/assets/babf154c-9cda-40ff-b2e8-5053e447c35f --- .../CommandMenuPageLayoutWidgetTypeSelect.tsx | 94 ++++++++++++- ...getFrontComponentWidgetTypeSelectItemId.ts | 3 + .../queries/findManyFrontComponents.ts | 11 ++ .../page-layout/constants/WidgetSizes.ts | 4 + ...useCreatePageLayoutFrontComponentWidget.ts | 125 ++++++++++++++++++ .../createDefaultFrontComponentWidget.ts | 32 +++++ .../FrontComponentWidgetRenderer.tsx | 22 ++- 7 files changed, 281 insertions(+), 10 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId.ts create mode 100644 packages/twenty-front/src/modules/front-components/graphql/queries/findManyFrontComponents.ts create mode 100644 packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutFrontComponentWidget.ts create mode 100644 packages/twenty-front/src/modules/page-layout/utils/createDefaultFrontComponentWidget.ts diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx index efeca81ad43..3445fb6b546 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect.tsx @@ -4,26 +4,36 @@ import { CommandMenuList } from '@/command-menu/components/CommandMenuList'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { useNavigatePageLayoutCommandMenu } from '@/command-menu/pages/page-layout/hooks/useNavigatePageLayoutCommandMenu'; import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord'; -import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { getFrontComponentWidgetTypeSelectItemId } from '@/command-menu/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId'; import { isExistingWidgetMissingOrDifferentType } from '@/command-menu/pages/page-layout/utils/isExistingWidgetMissingOrDifferentType'; -import { useOpportunityDefaultChartConfig } from '@/page-layout/hooks/useOpportunityDefaultChartConfig'; +import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages'; +import { FIND_MANY_FRONT_COMPONENTS } from '@/front-components/graphql/queries/findManyFrontComponents'; +import { useCreatePageLayoutFrontComponentWidget } from '@/page-layout/hooks/useCreatePageLayoutFrontComponentWidget'; import { useCreatePageLayoutGraphWidget } from '@/page-layout/hooks/useCreatePageLayoutGraphWidget'; import { useCreatePageLayoutIframeWidget } from '@/page-layout/hooks/useCreatePageLayoutIframeWidget'; import { useCreatePageLayoutStandaloneRichTextWidget } from '@/page-layout/hooks/useCreatePageLayoutStandaloneRichTextWidget'; +import { useOpportunityDefaultChartConfig } from '@/page-layout/hooks/useOpportunityDefaultChartConfig'; import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hooks/useRemovePageLayoutWidgetAndPreservePosition'; import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState'; import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useQuery } from '@apollo/client'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { IconAlignBoxLeftTop, + IconApps, IconChartPie, IconFrame, } from 'twenty-ui/display'; -import { WidgetType } from '~/generated/graphql'; +import { + FeatureFlagKey, + type FrontComponent, + WidgetType, +} from '~/generated/graphql'; export const CommandMenuPageLayoutWidgetTypeSelect = () => { const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord(); @@ -43,9 +53,31 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => { const { createPageLayoutStandaloneRichTextWidget } = useCreatePageLayoutStandaloneRichTextWidget(pageLayoutId); + const { createPageLayoutFrontComponentWidget } = + useCreatePageLayoutFrontComponentWidget(pageLayoutId); + const { removePageLayoutWidgetAndPreservePosition } = useRemovePageLayoutWidgetAndPreservePosition(pageLayoutId); + const isApplicationEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_APPLICATION_ENABLED, + ); + + const { data: frontComponentsData } = useQuery<{ + frontComponents: FrontComponent[]; + }>(FIND_MANY_FRONT_COMPONENTS, { + skip: !isApplicationEnabled, + }); + + const frontComponents = frontComponentsData?.frontComponents ?? []; + + const frontComponentsWithSelectItemId = frontComponents.map( + (frontComponent) => ({ + frontComponent, + selectItemId: getFrontComponentWidgetTypeSelectItemId(frontComponent.id), + }), + ); + const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] = useRecoilComponentState( pageLayoutEditingWidgetIdComponentState, @@ -127,11 +159,36 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => { closeCommandMenu(); }; + const handleCreateFrontComponentWidget = (frontComponent: FrontComponent) => { + if ( + isExistingWidgetMissingOrDifferentType( + existingWidget?.type, + WidgetType.FRONT_COMPONENT, + ) + ) { + if (isDefined(pageLayoutEditingWidgetId)) { + removePageLayoutWidgetAndPreservePosition(pageLayoutEditingWidgetId); + } + + const newWidget = createPageLayoutFrontComponentWidget( + frontComponent.name, + frontComponent.id, + ); + setPageLayoutEditingWidgetId(newWidget.id); + } + + closeCommandMenu(); + }; + + const selectableItemIds = [ + 'chart', + 'iframe', + 'rich-text', + ...frontComponentsWithSelectItemId.map(({ selectItemId }) => selectItemId), + ]; + return ( - + { /> + + {isApplicationEnabled && frontComponentsWithSelectItemId.length > 0 && ( + + {frontComponentsWithSelectItemId.map( + ({ frontComponent, selectItemId }) => ( + handleCreateFrontComponentWidget(frontComponent)} + > + + handleCreateFrontComponentWidget(frontComponent) + } + /> + + ), + )} + + )} ); }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId.ts new file mode 100644 index 00000000000..4714222ce6f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getFrontComponentWidgetTypeSelectItemId.ts @@ -0,0 +1,3 @@ +export const getFrontComponentWidgetTypeSelectItemId = ( + frontComponentId: string, +): string => `front-component-${frontComponentId}`; diff --git a/packages/twenty-front/src/modules/front-components/graphql/queries/findManyFrontComponents.ts b/packages/twenty-front/src/modules/front-components/graphql/queries/findManyFrontComponents.ts new file mode 100644 index 00000000000..d0f1a8b0d17 --- /dev/null +++ b/packages/twenty-front/src/modules/front-components/graphql/queries/findManyFrontComponents.ts @@ -0,0 +1,11 @@ +import gql from 'graphql-tag'; + +export const FIND_MANY_FRONT_COMPONENTS = gql` + query FindManyFrontComponents { + frontComponents { + id + name + applicationId + } + } +`; diff --git a/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts b/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts index 952442df89e..216f92f7766 100644 --- a/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts +++ b/packages/twenty-front/src/modules/page-layout/constants/WidgetSizes.ts @@ -10,4 +10,8 @@ export const WIDGET_SIZES: Partial> = { default: { w: 4, h: 4 }, minimum: { w: 2, h: 2 }, }, + [WidgetType.FRONT_COMPONENT]: { + default: { w: 8, h: 8 }, + minimum: { w: 4, h: 4 }, + }, }; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutFrontComponentWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutFrontComponentWidget.ts new file mode 100644 index 00000000000..c84c861bb14 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutFrontComponentWidget.ts @@ -0,0 +1,125 @@ +import { WIDGET_SIZES } from '@/page-layout/constants/WidgetSizes'; +import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; +import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState'; +import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState'; +import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; +import { addWidgetToTab } from '@/page-layout/utils/addWidgetToTab'; +import { createDefaultFrontComponentWidget } from '@/page-layout/utils/createDefaultFrontComponentWidget'; +import { getDefaultWidgetPosition } from '@/page-layout/utils/getDefaultWidgetPosition'; +import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId'; +import { getUpdatedTabLayouts } from '@/page-layout/utils/getUpdatedTabLayouts'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 as uuidv4 } from 'uuid'; +import { WidgetType } from '~/generated/graphql'; + +export const useCreatePageLayoutFrontComponentWidget = ( + pageLayoutIdFromProps?: string, +) => { + const pageLayoutId = useAvailableComponentInstanceIdOrThrow( + PageLayoutComponentInstanceContext, + pageLayoutIdFromProps, + ); + + const activeTabId = useRecoilComponentValue( + activeTabIdComponentState, + getTabListInstanceIdFromPageLayoutId(pageLayoutId), + ); + + const pageLayoutCurrentLayoutsState = useRecoilComponentCallbackState( + pageLayoutCurrentLayoutsComponentState, + pageLayoutId, + ); + + const pageLayoutDraggedAreaState = useRecoilComponentCallbackState( + pageLayoutDraggedAreaComponentState, + pageLayoutId, + ); + + const pageLayoutDraftState = useRecoilComponentCallbackState( + pageLayoutDraftComponentState, + pageLayoutId, + ); + + const createPageLayoutFrontComponentWidget = useRecoilCallback( + ({ snapshot, set }) => + (title: string, frontComponentId: string): PageLayoutWidget => { + const allTabLayouts = snapshot + .getLoadable(pageLayoutCurrentLayoutsState) + .getValue(); + + const pageLayoutDraggedArea = snapshot + .getLoadable(pageLayoutDraggedAreaState) + .getValue(); + + if (!isDefined(activeTabId)) { + throw new Error( + 'A tab must be selected to create a new front component widget', + ); + } + + const widgetId = uuidv4(); + const frontComponentSize = WIDGET_SIZES[WidgetType.FRONT_COMPONENT]!; + const defaultSize = frontComponentSize.default; + const minimumSize = frontComponentSize.minimum; + const position = getDefaultWidgetPosition( + pageLayoutDraggedArea, + defaultSize, + minimumSize, + ); + + const newWidget = createDefaultFrontComponentWidget( + widgetId, + activeTabId, + title, + frontComponentId, + { + row: position.y, + column: position.x, + rowSpan: position.h, + columnSpan: position.w, + }, + ); + + const newLayout = { + i: widgetId, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + minW: minimumSize.w, + minH: minimumSize.h, + }; + + const updatedLayouts = getUpdatedTabLayouts( + allTabLayouts, + activeTabId, + newLayout, + ); + + set(pageLayoutCurrentLayoutsState, updatedLayouts); + + set(pageLayoutDraftState, (prev) => ({ + ...prev, + tabs: addWidgetToTab(prev.tabs, activeTabId, newWidget), + })); + + set(pageLayoutDraggedAreaState, null); + + return newWidget; + }, + [ + activeTabId, + pageLayoutCurrentLayoutsState, + pageLayoutDraftState, + pageLayoutDraggedAreaState, + ], + ); + + return { createPageLayoutFrontComponentWidget }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/utils/createDefaultFrontComponentWidget.ts b/packages/twenty-front/src/modules/page-layout/utils/createDefaultFrontComponentWidget.ts new file mode 100644 index 00000000000..d1ab5ab8604 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/utils/createDefaultFrontComponentWidget.ts @@ -0,0 +1,32 @@ +import { + type GridPosition, + WidgetConfigurationType, + WidgetType, +} from '~/generated/graphql'; +import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; + +export const createDefaultFrontComponentWidget = ( + id: string, + pageLayoutTabId: string, + title: string, + frontComponentId: string, + gridPosition: GridPosition, +): PageLayoutWidget => { + return { + __typename: 'PageLayoutWidget', + id, + pageLayoutTabId, + title, + type: WidgetType.FRONT_COMPONENT, + configuration: { + __typename: 'FrontComponentConfiguration', + configurationType: WidgetConfigurationType.FRONT_COMPONENT, + frontComponentId, + }, + gridPosition, + objectMetadataId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx index 9b9a6c50ff4..b817e35a2c4 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/front-component/components/FrontComponentWidgetRenderer.tsx @@ -1,10 +1,20 @@ +import styled from '@emotion/styled'; import { Suspense, lazy } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType'; +import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState'; import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget'; import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +const StyledContainer = styled.div<{ isInEditMode: boolean }>` + height: 100%; + overflow: auto; + pointer-events: ${({ isInEditMode }) => (isInEditMode ? 'none' : 'auto')}; + width: 100%; +`; const FrontComponentRenderer = lazy(() => import('@/front-components/components/FrontComponentRenderer').then( @@ -19,6 +29,10 @@ type FrontComponentWidgetRendererProps = { export const FrontComponentWidgetRenderer = ({ widget, }: FrontComponentWidgetRendererProps) => { + const isPageLayoutInEditMode = useRecoilComponentValue( + isPageLayoutInEditModeComponentState, + ); + const configuration = widget.configuration; if ( @@ -31,8 +45,10 @@ export const FrontComponentWidgetRenderer = ({ const frontComponentId = configuration.frontComponentId; return ( - - - + + + + + ); };