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 - <img width="1535" height="1239" alt="Screenshot 2025-10-28 at 00 46 23" src="https://github.com/user-attachments/assets/9293983e-2d08-427b-971f-731e6f1517aa" />
This commit is contained in:
@@ -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<HTMLDivElement>(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(
|
||||
|
||||
+6
-1
@@ -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}
|
||||
>
|
||||
<StyledIcon size={theme.spacing(4)} stroke={theme.spacing(1)} />
|
||||
<StyledIcon
|
||||
size={theme.spacing(4)}
|
||||
stroke={theme.spacing(1)}
|
||||
className="widget-card-resize-handle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
+19
-2
@@ -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 (
|
||||
<DragDropContext onDragEnd={onReorder}>
|
||||
<DragDropContext
|
||||
onDragStart={(result) => {
|
||||
setDraggingWidgetId(result.draggableId);
|
||||
}}
|
||||
onDragEnd={(result) => {
|
||||
setDraggingWidgetId(null);
|
||||
onReorder(result);
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId={droppableId}>
|
||||
{(provided) => (
|
||||
<StyledVerticalListContainer
|
||||
@@ -53,7 +67,10 @@ export const PageLayoutVerticalListEditor = ({
|
||||
>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<div {...provided.dragHandleProps}>
|
||||
<WidgetRenderer widget={widget} />
|
||||
<WidgetRenderer
|
||||
widget={widget}
|
||||
widgetCardContext="recordPage"
|
||||
/>
|
||||
</div>
|
||||
</StyledDraggableWrapper>
|
||||
)}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const PageLayoutVerticalListViewer = ({
|
||||
<StyledVerticalListContainer>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetRenderer widget={widget} />
|
||||
<WidgetRenderer widget={widget} widgetCardContext="recordPage" />
|
||||
</div>
|
||||
))}
|
||||
</StyledVerticalListContainer>
|
||||
|
||||
+11
@@ -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,
|
||||
});
|
||||
@@ -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 (
|
||||
<StyledContainer
|
||||
onClick={onClick}
|
||||
isPageLayoutInEditMode={isPageLayoutInEditMode}
|
||||
>
|
||||
{children}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<StyledHeader>
|
||||
{!isEmpty && isInEditMode && (
|
||||
<WidgetGrip
|
||||
className="drag-handle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<StyledTitle>{isEmpty ? 'Add Widget' : title}</StyledTitle>
|
||||
{!isEmpty && isInEditMode && onRemove && (
|
||||
<IconButton
|
||||
onClick={onRemove}
|
||||
Icon={IconX}
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</StyledHeader>
|
||||
);
|
||||
};
|
||||
+10
-5
@@ -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 (
|
||||
<WidgetContainer onClick={handleClick}>
|
||||
<WidgetHeader
|
||||
<WidgetCard
|
||||
onClick={handleClick}
|
||||
widgetCardContext="dashboard"
|
||||
isEditing={false}
|
||||
isDragging={false}
|
||||
>
|
||||
<WidgetCardHeader
|
||||
isInEditMode={isPageLayoutInEditMode}
|
||||
title={t`Add Widget`}
|
||||
isEmpty
|
||||
@@ -62,6 +67,6 @@ export const WidgetPlaceholder = () => {
|
||||
</AnimatedPlaceholderEmptySubTitle>
|
||||
</AnimatedPlaceholderEmptyTextContainer>
|
||||
</AnimatedPlaceholderEmptyContainer>
|
||||
</WidgetContainer>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
+42
-25
@@ -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 (
|
||||
<WidgetContainer onClick={isPageLayoutInEditMode ? handleClick : undefined}>
|
||||
<WidgetHeader
|
||||
<WidgetCard
|
||||
onClick={isPageLayoutInEditMode ? handleClick : undefined}
|
||||
isDragging={isDragging}
|
||||
widgetCardContext={widgetCardContext}
|
||||
isEditing={isEditing}
|
||||
>
|
||||
<WidgetCardHeader
|
||||
isInEditMode={isPageLayoutInEditMode}
|
||||
title={widget.title}
|
||||
onRemove={handleRemove}
|
||||
forbiddenDisplay={
|
||||
!hasAccess && (
|
||||
<PageLayoutWidgetForbiddenDisplay
|
||||
widgetId={widget.id}
|
||||
restriction={restriction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StyledContent>
|
||||
{!hasAccess ? (
|
||||
<PageLayoutWidgetForbiddenDisplay
|
||||
widgetId={widget.id}
|
||||
restriction={restriction}
|
||||
/>
|
||||
) : (
|
||||
<WidgetContentRenderer widget={widget} />
|
||||
)}
|
||||
</StyledContent>
|
||||
</WidgetContainer>
|
||||
<WidgetCardContent widgetCardContext={widgetCardContext}>
|
||||
{hasAccess && <WidgetContentRenderer widget={widget} />}
|
||||
</WidgetCardContent>
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
|
||||
+152
@@ -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 (
|
||||
<StyledWidgetCard
|
||||
onClick={onClick}
|
||||
widgetCardContext={widgetCardContext}
|
||||
isPageLayoutInEditMode={isPageLayoutInEditMode}
|
||||
isEditing={isEditing}
|
||||
isDragging={isDragging}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</StyledWidgetCard>
|
||||
);
|
||||
};
|
||||
+40
@@ -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<WidgetCardContentProps>`
|
||||
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 };
|
||||
+72
@@ -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 (
|
||||
<StyledWidgetCardHeader className={className}>
|
||||
{!isEmpty && isInEditMode && (
|
||||
<WidgetGrip
|
||||
className="drag-handle"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<StyledTitleContainer>
|
||||
<OverflowingTextWithTooltip text={isEmpty ? t`Add Widget` : title} />
|
||||
</StyledTitleContainer>
|
||||
{isDefined(forbiddenDisplay) && forbiddenDisplay}
|
||||
{!isEmpty && isInEditMode && onRemove && (
|
||||
<StyledIconButton
|
||||
onClick={onRemove}
|
||||
Icon={IconTrash}
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
className="widget-card-remove-button"
|
||||
/>
|
||||
)}
|
||||
</StyledWidgetCardHeader>
|
||||
);
|
||||
};
|
||||
+193
@@ -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<typeof WidgetCard> = {
|
||||
title: 'Modules/PageLayout/Widgets/WidgetCard',
|
||||
component: WidgetCard,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WidgetCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
return (
|
||||
<PageLayoutTestWrapper>
|
||||
<EditModeWrapper isInEditMode={true}>
|
||||
<StyledContainer>
|
||||
<Story />
|
||||
</StyledContainer>
|
||||
</EditModeWrapper>
|
||||
</PageLayoutTestWrapper>
|
||||
);
|
||||
},
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
parameters: {
|
||||
pseudo: { hover: false },
|
||||
},
|
||||
args: {
|
||||
widgetCardContext: 'dashboard',
|
||||
isEditing: false,
|
||||
isDragging: false,
|
||||
},
|
||||
render: (args) => (
|
||||
<WidgetCard
|
||||
widgetCardContext={args.widgetCardContext}
|
||||
isEditing={args.isEditing}
|
||||
isDragging={args.isDragging}
|
||||
>
|
||||
<WidgetCardHeader
|
||||
isInEditMode={true}
|
||||
onRemove={() => {}}
|
||||
title="Widget name"
|
||||
/>
|
||||
<WidgetCardContent widgetCardContext={args.widgetCardContext}>
|
||||
<StyledMockContent>Widget</StyledMockContent>
|
||||
</WidgetCardContent>
|
||||
</WidgetCard>
|
||||
),
|
||||
};
|
||||
|
||||
export const Catalog: CatalogStory<Story, typeof WidgetCard> = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<Story />
|
||||
</StyledContainer>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<PageLayoutTestWrapper>
|
||||
<EditModeWrapper isInEditMode={!isReadMode}>
|
||||
<WidgetCard
|
||||
className={args.className}
|
||||
isDragging={args.isDragging ?? false}
|
||||
isEditing={args.isEditing ?? false}
|
||||
widgetCardContext={widgetCardContext}
|
||||
>
|
||||
<WidgetCardHeader
|
||||
forbiddenDisplay={
|
||||
hasRestriction ? <ForbiddenFieldDisplay /> : undefined
|
||||
}
|
||||
isInEditMode={!isReadMode}
|
||||
onRemove={!isReadMode ? () => {} : undefined}
|
||||
title="Widget name"
|
||||
/>
|
||||
<WidgetCardContent widgetCardContext={widgetCardContext}>
|
||||
<StyledMockContent>Widget</StyledMockContent>
|
||||
</WidgetCardContent>
|
||||
</WidgetCard>
|
||||
</EditModeWrapper>
|
||||
</PageLayoutTestWrapper>
|
||||
);
|
||||
},
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type WidgetCardContext = 'dashboard' | 'recordPage';
|
||||
Reference in New Issue
Block a user