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 - Screenshot 2025-10-28 at 00 46 23 --- .../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';