From 0fdf76560d346f7ac27714c03b505d3598b9c7dd Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 28 Oct 2025 20:25:52 +0530
Subject: [PATCH] Widget cards (#15387)
figma -
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=79795-372071&t=0Rg3h23G8F7v80cs-0
for the pinned tab column or the left column -- there is only one state
change! -- which, after talks with @Bonapara -- we should try to keep
the same if possible! -- right now, there is no way to test it -- so I
did not add the same
closes -
https://discord.com/channels/1130383047699738754/1430825706643918942 and
https://discord.com/channels/1130383047699738754/1430599763807436870
story QA -
---
.../components/PageLayoutGridLayout.tsx | 17 +-
.../components/PageLayoutGridResizeHandle.tsx | 7 +-
.../PageLayoutVerticalListEditor.tsx | 21 +-
.../PageLayoutVerticalListViewer.tsx | 2 +-
...ageLayoutDraggingWidgetIdComponentState.ts | 11 +
.../widgets/components/WidgetContainer.tsx | 54 -----
.../widgets/components/WidgetHeader.tsx | 55 -----
.../widgets/components/WidgetPlaceholder.tsx | 15 +-
.../widgets/components/WidgetRenderer.tsx | 67 +++---
.../widget-card/components/WidgetCard.tsx | 152 ++++++++++++++
.../components/WidgetCardContent.tsx | 40 ++++
.../components/WidgetCardHeader.tsx | 72 +++++++
.../components/WidgetGrip.tsx | 0
.../__stories__/WidgetCard.stories.tsx | 193 ++++++++++++++++++
.../widget-card/types/WidgetCardContext.ts | 1 +
15 files changed, 563 insertions(+), 144 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/states/pageLayoutDraggingWidgetIdComponentState.ts
delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContainer.tsx
delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/WidgetHeader.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx
rename packages/twenty-front/src/modules/page-layout/widgets/{ => widget-card}/components/WidgetGrip.tsx (100%)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/widget-card/types/WidgetCardContext.ts
diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx
index 5977090c39..02ff132275 100644
--- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx
+++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridLayout.tsx
@@ -11,6 +11,7 @@ import { usePageLayoutHandleLayoutChange } from '@/page-layout/hooks/usePageLayo
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutCurrentBreakpointComponentState } from '@/page-layout/states/pageLayoutCurrentBreakpointComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
+import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { WidgetPlaceholder } from '@/page-layout/widgets/components/WidgetPlaceholder';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
@@ -39,12 +40,16 @@ const StyledGridContainer = styled.div`
.react-grid-placeholder {
background: ${({ theme }) => theme.adaptiveColors.blue3} !important;
- border-radius: ${({ theme }) => theme.border.radius.sm};
+ border-radius: ${({ theme }) => theme.border.radius.md};
}
.react-grid-item:not(.react-draggable-dragging) {
user-select: auto;
}
+
+ .react-grid-item:hover .widget-card-resize-handle {
+ display: block !important;
+ }
`;
type ExtendedResponsiveProps = ResponsiveProps & {
@@ -75,6 +80,10 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
pageLayoutCurrentBreakpointComponentState,
);
+ const setDraggingWidgetId = useSetRecoilComponentState(
+ pageLayoutDraggingWidgetIdComponentState,
+ );
+
const { handleLayoutChange } = usePageLayoutHandleLayoutChange();
const gridContainerRef = useRef(null);
@@ -153,6 +162,12 @@ export const PageLayoutGridLayout = ({ tabId }: PageLayoutGridLayoutProps) => {
) : undefined
}
resizeHandles={['se']}
+ onDragStart={(_layout, _oldItem, newItem) => {
+ setDraggingWidgetId(newItem.i);
+ }}
+ onDragStop={() => {
+ setDraggingWidgetId(null);
+ }}
onLayoutChange={handleLayoutChange}
onBreakpointChange={(newBreakpoint) =>
setPageLayoutCurrentBreakpoint(
diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridResizeHandle.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridResizeHandle.tsx
index c125a13129..39ef81f12d 100644
--- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridResizeHandle.tsx
+++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutGridResizeHandle.tsx
@@ -18,6 +18,7 @@ const StyledIcon = styled(IconRadiusBottomRight)`
position: absolute;
right: ${({ theme }) => theme.spacing(1)};
color: ${({ theme }) => theme.border.color.strong};
+ display: none;
:hover {
color: ${({ theme }) => theme.font.color.tertiary};
@@ -49,7 +50,11 @@ export const PageLayoutGridResizeHandle = forwardRef<
className={className}
style={style}
>
-
+
);
});
diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx
index 0172f8d49a..4ddd8e55c7 100644
--- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx
+++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListEditor.tsx
@@ -1,4 +1,6 @@
+import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
import { WidgetRenderer } from '@/page-layout/widgets/components/WidgetRenderer';
+import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import {
DragDropContext,
@@ -33,8 +35,20 @@ export const PageLayoutVerticalListEditor = ({
}: PageLayoutVerticalListEditorProps) => {
const droppableId = `page-layout-vertical-list-${useId()}`;
+ const setDraggingWidgetId = useSetRecoilComponentState(
+ pageLayoutDraggingWidgetIdComponentState,
+ );
+
return (
-
+ {
+ setDraggingWidgetId(result.draggableId);
+ }}
+ onDragEnd={(result) => {
+ setDraggingWidgetId(null);
+ onReorder(result);
+ }}
+ >
{(provided) => (
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
-
+
)}
diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListViewer.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListViewer.tsx
index b33c198696..2b2a4d5a93 100644
--- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListViewer.tsx
+++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutVerticalListViewer.tsx
@@ -19,7 +19,7 @@ export const PageLayoutVerticalListViewer = ({
{widgets.map((widget) => (
-
+
))}
diff --git a/packages/twenty-front/src/modules/page-layout/states/pageLayoutDraggingWidgetIdComponentState.ts b/packages/twenty-front/src/modules/page-layout/states/pageLayoutDraggingWidgetIdComponentState.ts
new file mode 100644
index 0000000000..4a16f216be
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/states/pageLayoutDraggingWidgetIdComponentState.ts
@@ -0,0 +1,11 @@
+import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
+
+import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
+
+export const pageLayoutDraggingWidgetIdComponentState = createComponentState<
+ string | null
+>({
+ key: 'pageLayoutDraggingWidgetIdComponentState',
+ defaultValue: null,
+ componentInstanceContext: PageLayoutComponentInstanceContext,
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContainer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContainer.tsx
deleted file mode 100644
index f2c24cd025..0000000000
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetContainer.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
-import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
-import styled from '@emotion/styled';
-import { type ReactNode } from 'react';
-import { isDefined } from 'twenty-shared/utils';
-
-const StyledContainer = styled.div<{
- onClick?: () => void;
- isPageLayoutInEditMode?: boolean;
-}>`
- background: ${({ theme }) => theme.background.secondary};
- border: 1px solid ${({ theme }) => theme.border.color.medium};
- border-radius: ${({ theme }) => theme.border.radius.md};
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- height: 100%;
- width: 100%;
- position: relative;
- padding: ${({ theme }) => theme.spacing(4)};
-
- ${({ onClick, theme, isPageLayoutInEditMode }) =>
- isPageLayoutInEditMode &&
- `
- &:hover {
- cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
- border: 1px solid ${theme.color.blue};
- background: ${theme.background.primary};
- }
- `}
-`;
-
-type WidgetContainerProps = {
- children?: ReactNode;
- onClick?: () => void;
-};
-
-export const WidgetContainer = ({
- children,
- onClick,
-}: WidgetContainerProps) => {
- const isPageLayoutInEditMode = useRecoilComponentValue(
- isPageLayoutInEditModeComponentState,
- );
-
- return (
-
- {children}
-
- );
-};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetHeader.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetHeader.tsx
deleted file mode 100644
index 0d24e0fa10..0000000000
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetHeader.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import styled from '@emotion/styled';
-import { IconX } from 'twenty-ui/display';
-import { IconButton } from 'twenty-ui/input';
-
-import { WidgetGrip } from './WidgetGrip';
-
-const StyledHeader = styled.div`
- align-items: center;
- display: flex;
- gap: ${({ theme }) => theme.spacing(2)};
- height: ${({ theme }) => theme.spacing(6)};
- flex-shrink: 0;
-`;
-
-const StyledTitle = styled.span`
- color: ${({ theme }) => theme.font.color.primary};
- flex: 1;
- font-size: ${({ theme }) => theme.font.size.sm};
- font-weight: ${({ theme }) => theme.font.weight.medium};
- user-select: none;
-`;
-
-type WidgetHeaderProps = {
- isInEditMode: boolean;
- isEmpty?: boolean;
- title: string;
- onRemove?: (e?: React.MouseEvent) => void;
-};
-
-export const WidgetHeader = ({
- isEmpty = false,
- isInEditMode = false,
- title,
- onRemove,
-}: WidgetHeaderProps) => {
- return (
-
- {!isEmpty && isInEditMode && (
- e.stopPropagation()}
- />
- )}
- {isEmpty ? 'Add Widget' : title}
- {!isEmpty && isInEditMode && onRemove && (
-
- )}
-
- );
-};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx
index 35cffd1db7..32d1777a75 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetPlaceholder.tsx
@@ -3,8 +3,8 @@ import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
-import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer';
-import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader';
+import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
+import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
@@ -42,8 +42,13 @@ export const WidgetPlaceholder = () => {
};
return (
-
-
+ {
-
+
);
};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx
index fb581fe244..53feb48e82 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetRenderer.tsx
@@ -1,29 +1,28 @@
import { useDeletePageLayoutWidget } from '@/page-layout/hooks/useDeletePageLayoutWidget';
import { useEditPageLayoutWidget } from '@/page-layout/hooks/useEditPageLayoutWidget';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
+import { pageLayoutDraggingWidgetIdComponentState } from '@/page-layout/states/pageLayoutDraggingWidgetIdComponentState';
+import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
-import { WidgetContainer } from '@/page-layout/widgets/components/WidgetContainer';
import { WidgetContentRenderer } from '@/page-layout/widgets/components/WidgetContentRenderer';
-import { WidgetHeader } from '@/page-layout/widgets/components/WidgetHeader';
import { useWidgetPermissions } from '@/page-layout/widgets/hooks/useWidgetPermissions';
+import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
+import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
+import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
+import { type WidgetCardContext } from '@/page-layout/widgets/widget-card/types/WidgetCardContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
-import styled from '@emotion/styled';
import { type MouseEvent } from 'react';
import { type PageLayoutWidget } from '~/generated/graphql';
type WidgetRendererProps = {
widget: PageLayoutWidget;
+ widgetCardContext?: WidgetCardContext;
};
-const StyledContent = styled.div`
- align-items: center;
- display: flex;
- height: 100%;
- width: 100%;
- justify-content: center;
-`;
-
-export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
+export const WidgetRenderer = ({
+ widget,
+ widgetCardContext = 'dashboard',
+}: WidgetRendererProps) => {
const { deletePageLayoutWidget } = useDeletePageLayoutWidget();
const { handleEditWidget } = useEditPageLayoutWidget();
@@ -31,6 +30,18 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
isPageLayoutInEditModeComponentState,
);
+ const draggingWidgetId = useRecoilComponentValue(
+ pageLayoutDraggingWidgetIdComponentState,
+ );
+
+ const currentlyEditingWidgetId = useRecoilComponentValue(
+ pageLayoutEditingWidgetIdComponentState,
+ );
+
+ const isEditing = currentlyEditingWidgetId === widget.id;
+
+ const isDragging = draggingWidgetId === widget.id;
+
const { hasAccess, restriction } = useWidgetPermissions(widget);
const handleClick = () => {
@@ -46,22 +57,28 @@ export const WidgetRenderer = ({ widget }: WidgetRendererProps) => {
};
return (
-
-
+
+ )
+ }
/>
-
- {!hasAccess ? (
-
- ) : (
-
- )}
-
-
+
+ {hasAccess && }
+
+
);
};
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
new file mode 100644
index 0000000000..3684fb467b
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCard.tsx
@@ -0,0 +1,152 @@
+import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
+import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { type ReactNode } from 'react';
+import { assertUnreachable, isDefined } from 'twenty-shared/utils';
+
+import { type WidgetCardContext } from '../types/WidgetCardContext';
+
+export type WidgetCardProps = {
+ children?: ReactNode;
+ widgetCardContext: WidgetCardContext;
+ onClick?: () => void;
+ isEditing: boolean;
+ isDragging: boolean;
+ className?: string;
+};
+
+const StyledWidgetCard = styled.div<{
+ onClick?: () => void;
+ widgetCardContext: WidgetCardContext;
+ isPageLayoutInEditMode: boolean;
+ isEditing: boolean;
+ isDragging: boolean;
+}>`
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ position: relative;
+
+ ${({
+ theme,
+ widgetCardContext,
+ isPageLayoutInEditMode,
+ isEditing,
+ isDragging,
+ onClick,
+ }) => {
+ switch (widgetCardContext) {
+ case 'dashboard':
+ return css`
+ background: ${theme.background.secondary};
+ border: 1px solid ${theme.border.color.light};
+ border-radius: ${theme.border.radius.md};
+ padding: ${theme.spacing(2)};
+ gap: ${theme.spacing(2)};
+
+ ${isPageLayoutInEditMode &&
+ !isDragging &&
+ !isEditing &&
+ css`
+ &:hover {
+ cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
+ border: 1px solid ${theme.border.color.strong};
+
+ .widget-card-remove-button {
+ display: block !important;
+ }
+ }
+ `}
+
+ ${isEditing &&
+ !isDragging &&
+ css`
+ border: 1px solid ${theme.color.blue} !important;
+ `}
+
+ ${isDragging &&
+ css`
+ background: linear-gradient(
+ 0deg,
+ ${theme.background.transparent.lighter} 0%,
+ ${theme.background.transparent.lighter} 100%
+ ),
+ ${theme.background.secondary};
+ border: 1px solid ${theme.color.blue} !important;
+ `}
+ `;
+
+ case 'recordPage':
+ return css`
+ background: ${theme.background.primary};
+ border: 1px solid transparent;
+ border-radius: ${theme.border.radius.md};
+ gap: ${theme.spacing(2)};
+ padding: ${theme.spacing(2)};
+
+ ${isPageLayoutInEditMode &&
+ !isDragging &&
+ !isEditing &&
+ css`
+ &:hover {
+ cursor: ${isDefined(onClick) ? 'pointer' : 'default'};
+ border: 1px solid ${theme.border.color.strong};
+
+ .widget-card-remove-button {
+ display: block !important;
+ }
+ }
+ `}
+
+ ${isEditing &&
+ !isDragging &&
+ css`
+ border: 1px solid ${theme.color.blue} !important;
+ `}
+
+ ${isDragging &&
+ css`
+ background: linear-gradient(
+ 0deg,
+ ${theme.background.transparent.lighter} 0%,
+ ${theme.background.transparent.lighter} 100%
+ ),
+ ${theme.background.secondary};
+ border: 1px solid ${theme.color.blue} !important;
+ `}
+ `;
+
+ default:
+ return assertUnreachable(widgetCardContext);
+ }
+ }}
+`;
+
+export const WidgetCard = ({
+ children,
+ widgetCardContext,
+ onClick,
+ isEditing,
+ isDragging,
+ className,
+}: WidgetCardProps) => {
+ const isPageLayoutInEditMode = useRecoilComponentValue(
+ isPageLayoutInEditModeComponentState,
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx
new file mode 100644
index 0000000000..ad20e654f4
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardContent.tsx
@@ -0,0 +1,40 @@
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import { type ReactNode } from 'react';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import { type WidgetCardContext } from '../types/WidgetCardContext';
+
+export type WidgetCardContentProps = {
+ children?: ReactNode;
+ widgetCardContext: WidgetCardContext;
+ className?: string;
+};
+
+const StyledWidgetCardContent = styled.div`
+ align-items: center;
+ display: flex;
+ height: 100%;
+ width: 100%;
+ justify-content: center;
+ box-sizing: border-box;
+ padding: ${({ theme }) => theme.spacing(2)};
+
+ ${({ theme, widgetCardContext }) => {
+ switch (widgetCardContext) {
+ case 'recordPage':
+ return css`
+ border: 1px solid ${theme.border.color.medium};
+ border-radius: ${theme.border.radius.md};
+ `;
+
+ case 'dashboard':
+ return '';
+
+ default:
+ return assertUnreachable(widgetCardContext);
+ }
+ }}
+`;
+
+export { StyledWidgetCardContent as WidgetCardContent };
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx
new file mode 100644
index 0000000000..67635b2354
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetCardHeader.tsx
@@ -0,0 +1,72 @@
+import styled from '@emotion/styled';
+import { t } from '@lingui/core/macro';
+import { type ReactNode } from 'react';
+import { IconTrash, OverflowingTextWithTooltip } from 'twenty-ui/display';
+import { IconButton } from 'twenty-ui/input';
+
+import { WidgetGrip } from '@/page-layout/widgets/widget-card/components/WidgetGrip';
+import { isDefined } from 'twenty-shared/utils';
+
+export type WidgetCardHeaderProps = {
+ isInEditMode: boolean;
+ isEmpty?: boolean;
+ title: string;
+ onRemove?: (e?: React.MouseEvent) => void;
+ forbiddenDisplay?: ReactNode;
+ className?: string;
+};
+
+const StyledWidgetCardHeader = styled.div`
+ align-items: center;
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+ height: ${({ theme }) => theme.spacing(6)};
+ flex-shrink: 0;
+`;
+
+const StyledTitleContainer = styled.div`
+ color: ${({ theme }) => theme.font.color.primary};
+ flex: 1;
+ font-size: ${({ theme }) => theme.font.size.sm};
+ padding-inline: ${({ theme }) => theme.spacing(1)};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+ user-select: none;
+ overflow: hidden;
+`;
+
+const StyledIconButton = styled(IconButton)`
+ display: none;
+`;
+
+export const WidgetCardHeader = ({
+ isEmpty = false,
+ isInEditMode = false,
+ title,
+ onRemove,
+ forbiddenDisplay,
+ className,
+}: WidgetCardHeaderProps) => {
+ return (
+
+ {!isEmpty && isInEditMode && (
+ e.stopPropagation()}
+ />
+ )}
+
+
+
+ {isDefined(forbiddenDisplay) && forbiddenDisplay}
+ {!isEmpty && isInEditMode && onRemove && (
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/WidgetGrip.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetGrip.tsx
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/components/WidgetGrip.tsx
rename to packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/WidgetGrip.tsx
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx
new file mode 100644
index 0000000000..565f6f0895
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/components/__stories__/WidgetCard.stories.tsx
@@ -0,0 +1,193 @@
+import styled from '@emotion/styled';
+import { type Meta, type StoryObj } from '@storybook/react';
+import { useEffect, type ReactNode } from 'react';
+import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing';
+
+import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
+import { PageLayoutTestWrapper } from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper';
+import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
+import { WidgetCard } from '@/page-layout/widgets/widget-card/components/WidgetCard';
+import { WidgetCardContent } from '@/page-layout/widgets/widget-card/components/WidgetCardContent';
+import { WidgetCardHeader } from '@/page-layout/widgets/widget-card/components/WidgetCardHeader';
+import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
+import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
+
+const StyledContainer = styled.div`
+ height: 200px;
+ width: 300px;
+`;
+
+const StyledMockContent = styled.div`
+ align-items: center;
+ color: ${({ theme }) => theme.font.color.tertiary};
+ display: flex;
+ font-size: ${({ theme }) => theme.font.size.sm};
+ justify-content: center;
+`;
+
+const EditModeWrapper = ({
+ children,
+ isInEditMode,
+}: {
+ children: ReactNode;
+ isInEditMode: boolean;
+}) => {
+ const setIsPageLayoutInEditMode = useSetRecoilComponentState(
+ isPageLayoutInEditModeComponentState,
+ );
+
+ useEffect(() => {
+ setIsPageLayoutInEditMode(isInEditMode);
+ }, [isInEditMode, setIsPageLayoutInEditMode]);
+
+ return <>{children}>;
+};
+
+const meta: Meta = {
+ title: 'Modules/PageLayout/Widgets/WidgetCard',
+ component: WidgetCard,
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ decorators: [
+ (Story) => {
+ return (
+
+
+
+
+
+
+
+ );
+ },
+ I18nFrontDecorator,
+ ],
+ parameters: {
+ pseudo: { hover: false },
+ },
+ args: {
+ widgetCardContext: 'dashboard',
+ isEditing: false,
+ isDragging: false,
+ },
+ render: (args) => (
+
+ {}}
+ title="Widget name"
+ />
+
+ Widget
+
+
+ ),
+};
+
+export const Catalog: CatalogStory = {
+ decorators: [
+ (Story) => {
+ return (
+
+
+
+ );
+ },
+ CatalogDecorator,
+ I18nFrontDecorator,
+ ],
+ parameters: {
+ pseudo: { hover: ['.hover'] },
+ catalog: {
+ dimensions: [
+ {
+ name: 'contextVariant',
+ values: [
+ 'Record Page - Default',
+ 'Record Page - Restriction',
+ 'Dashboard - Default',
+ 'Dashboard - Restriction',
+ ],
+ props: (contextName: string) => {
+ const widgetCardContext = contextName.includes('Record')
+ ? 'recordPage'
+ : 'dashboard';
+ const hasRestriction = contextName.includes('Restriction');
+
+ return {
+ widgetCardContext,
+ contextVariant: contextName,
+ hasRestriction,
+ };
+ },
+ },
+ {
+ name: 'state',
+ values: ['Default', 'Hover', 'Selected', 'Dragging', 'Read Mode'],
+ props: (state: string) => {
+ const stateProps: {
+ className?: string;
+ isDragging?: boolean;
+ isEditing?: boolean;
+ } = {};
+
+ switch (state) {
+ case 'Hover':
+ stateProps.className = 'hover';
+ break;
+ case 'Selected':
+ stateProps.isEditing = true;
+ break;
+ case 'Dragging':
+ stateProps.isDragging = true;
+ break;
+ case 'Read Mode':
+ stateProps.className = 'read-mode';
+ break;
+ }
+
+ return { ...stateProps, state };
+ },
+ },
+ ],
+ },
+ },
+ render: (args: any) => {
+ const isReadMode = args.state === 'Read Mode';
+ const widgetCardContext = args.widgetCardContext || 'dashboard';
+ const hasRestriction = args.hasRestriction || false;
+
+ return (
+
+
+
+ : undefined
+ }
+ isInEditMode={!isReadMode}
+ onRemove={!isReadMode ? () => {} : undefined}
+ title="Widget name"
+ />
+
+ Widget
+
+
+
+
+ );
+ },
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/widget-card/types/WidgetCardContext.ts b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/types/WidgetCardContext.ts
new file mode 100644
index 0000000000..a5d206d983
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/widget-card/types/WidgetCardContext.ts
@@ -0,0 +1 @@
+export type WidgetCardContext = 'dashboard' | 'recordPage';