Create variants for workflow visualizer nodes (#10006)

Closes https://github.com/twentyhq/core-team-issues/issues/332

- Create the success and failed variants
- Introduce the first responsive color
- Creating stories for the new variants

These components are not yet in use in the source code. If you want to
see them, launch Storybook.

| Success | Failure |
|--------|--------|
| ![CleanShot 2025-02-04 at 16 24
43@2x](https://github.com/user-attachments/assets/0dd68a8f-3914-4b6e-b2d8-43108c2f5e8c)
| ![CleanShot 2025-02-04 at 16 24
59@2x](https://github.com/user-attachments/assets/e4e408d3-29fb-4fbc-a277-044aec9b0f4b)
|
| ![CleanShot 2025-02-04 at 16 24
54@2x](https://github.com/user-attachments/assets/d565ee47-1476-475d-adf6-dadfff9c6719)
| ![CleanShot 2025-02-04 at 16 25
05@2x](https://github.com/user-attachments/assets/9a0aabcc-84d1-41e2-a5a1-7c8cb05f963f)
|
This commit is contained in:
Baptiste Devessier
2025-02-04 18:38:38 +01:00
committed by GitHub
parent 5be22413c9
commit 125a0c3419
14 changed files with 284 additions and 102 deletions

View File

@ -4,14 +4,14 @@ import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/Node
import { NODE_ICON_LEFT_MARGIN } from '@/workflow/workflow-diagram/constants/NodeIconLeftMargin';
import { NODE_ICON_WIDTH } from '@/workflow/workflow-diagram/constants/NodeIconWidth';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import React from 'react';
import { capitalize, isDefined } from 'twenty-shared';
import { Label, OverflowingTextWithTooltip } from 'twenty-ui';
type Variant = 'placeholder';
const StyledStepNodeContainer = styled.div`
display: flex;
flex-direction: column;
@ -19,52 +19,109 @@ const StyledStepNodeContainer = styled.div`
padding-block: ${({ theme }) => theme.spacing(3)};
`;
const StyledStepNodeType = styled(Label)`
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm} 0 0;
const StyledStepNodeType = styled.div<{
nodeVariant: WorkflowDiagramNodeVariant;
}>`
${({ nodeVariant, theme }) => {
switch (nodeVariant) {
case 'success': {
return css`
background-color: ${theme.tag.background.turquoise};
color: ${theme.tag.text.turquoise};
`;
}
case 'failure': {
return css`
background-color: ${theme.tag.background.red};
color: ${theme.color.red};
`;
}
default: {
return css`
background-color: ${theme.background.tertiary};
`;
}
}
}}
align-self: flex-start;
border-radius: ${({ theme }) =>
`${theme.border.radius.sm} ${theme.border.radius.sm} 0 0`};
margin-left: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
align-self: flex-start;
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
${({ nodeVariant, theme }) => {
switch (nodeVariant) {
case 'empty':
case 'default': {
return css`
background-color: ${theme.color.blue};
color: ${theme.font.color.inverted};
`;
}
}
}}
}
`;
`.withComponent(Label);
const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>`
const StyledStepNodeInnerContainer = styled.div<{
variant: WorkflowDiagramNodeVariant;
}>`
background-color: ${({ theme }) => theme.background.secondary};
border: ${NODE_BORDER_WIDTH}px solid
${({ theme }) => theme.border.color.medium};
border-color: ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
border-style: solid;
border-width: ${NODE_BORDER_WIDTH}px;
box-shadow: ${({ variant, theme }) =>
variant === 'empty' ? 'none' : theme.boxShadow.strong};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
box-shadow: ${({ variant, theme }) =>
variant === 'placeholder' ? 'none' : theme.boxShadow.strong};
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.accent.quaternary};
border-color: ${({ theme }) => theme.color.blue};
${({ theme, variant }) => {
switch (variant) {
case 'success': {
return css`
background-color: ${theme.adaptiveColors.turquoise1};
border-color: ${theme.adaptiveColors.turquoise4};
`;
}
case 'failure': {
return css`
background-color: ${theme.background.danger};
border-color: ${theme.color.red};
`;
}
default: {
return css`
background-color: ${theme.accent.quaternary};
border-color: ${theme.color.blue};
`;
}
}
}}
}
`;
const StyledStepNodeLabel = styled.div<{ variant?: Variant }>`
const StyledStepNodeLabel = styled.div<{
variant: WorkflowDiagramNodeVariant;
}>`
align-items: center;
display: flex;
font-size: 13px;
font-weight: ${({ theme }) => theme.font.weight.medium};
column-gap: ${({ theme }) => theme.spacing(2)};
color: ${({ variant, theme }) =>
variant === 'placeholder'
variant === 'empty'
? theme.font.color.extraLight
: theme.font.color.primary};
max-width: 200px;
@ -106,7 +163,7 @@ export const WorkflowDiagramBaseStepNode = ({
}: {
nodeType: WorkflowDiagramStepNodeData['nodeType'];
name: string;
variant?: Variant;
variant: WorkflowDiagramNodeVariant;
Icon?: React.ReactNode;
RightFloatingElement?: React.ReactNode;
}) => {
@ -116,7 +173,7 @@ export const WorkflowDiagramBaseStepNode = ({
<StyledTargetHandle type="target" position={Position.Top} />
) : null}
<StyledStepNodeType variant="small">
<StyledStepNodeType variant="small" nodeVariant={variant}>
{capitalize(nodeType)}
</StyledStepNodeType>

View File

@ -15,7 +15,7 @@ export const WorkflowDiagramEmptyTrigger = () => {
<WorkflowDiagramBaseStepNode
name="Add a Trigger"
nodeType="trigger"
variant="placeholder"
variant="empty"
Icon={<StyledStepNodeLabelIconContainer />}
/>
);

View File

@ -1,6 +1,7 @@
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { WorkflowDiagramBaseStepNode } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseStepNode';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -17,9 +18,11 @@ const StyledStepNodeLabelIconContainer = styled.div`
export const WorkflowDiagramStepNodeBase = ({
data,
variant,
RightFloatingElement,
}: {
data: WorkflowDiagramStepNodeData;
variant: WorkflowDiagramNodeVariant;
RightFloatingElement?: React.ReactNode;
}) => {
const theme = useTheme();
@ -93,6 +96,7 @@ export const WorkflowDiagramStepNodeBase = ({
return (
<WorkflowDiagramBaseStepNode
name={data.name}
variant={variant}
nodeType={data.nodeType}
Icon={renderStepIcon()}
RightFloatingElement={RightFloatingElement}

View File

@ -27,6 +27,7 @@ export const WorkflowDiagramStepNodeEditable = ({
return (
<WorkflowDiagramStepNodeEditableContent
data={data}
variant="default"
selected={selected ?? false}
onDelete={() => {
deleteStep(id);

View File

@ -1,19 +1,23 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { FloatingIconButton, IconTrash } from 'twenty-ui';
export const WorkflowDiagramStepNodeEditableContent = ({
data,
selected,
variant,
onDelete,
}: {
data: WorkflowDiagramStepNodeData;
variant: WorkflowDiagramNodeVariant;
selected: boolean;
onDelete: () => void;
}) => {
return (
<WorkflowDiagramStepNodeBase
data={data}
variant={variant}
RightFloatingElement={
selected ? (
<FloatingIconButton

View File

@ -6,5 +6,5 @@ export const WorkflowDiagramStepNodeReadonly = ({
}: {
data: WorkflowDiagramStepNodeData;
}) => {
return <WorkflowDiagramStepNodeBase data={data} />;
return <WorkflowDiagramStepNodeBase variant="default" data={data} />;
};

View File

@ -2,9 +2,9 @@ import { Meta, StoryObj } from '@storybook/react';
import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { fn } from '@storybook/test';
import { ReactFlowProvider } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { CatalogDecorator, CatalogStory } from 'twenty-ui';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowDiagramStepNodeEditableContent } from '../WorkflowDiagramStepNodeEditableContent';
@ -17,12 +17,43 @@ export default meta;
type Story = StoryObj<typeof WorkflowDiagramStepNodeEditableContent>;
const ALL_STEPS = [
{
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Record is Created',
},
{ nodeType: 'trigger', triggerType: 'MANUAL', name: 'Manual' },
{
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Record',
},
{
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Record',
},
{
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Record',
},
{
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
},
{ nodeType: 'action', actionType: 'CODE', name: 'Code' },
] satisfies WorkflowDiagramStepNodeData[];
export const All: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'default',
selected: false,
},
parameters: {
@ -37,57 +68,22 @@ export const All: CatalogStory<
dimensions: [
{
name: 'step type',
values: [
{
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Record is Created',
},
{ nodeType: 'trigger', triggerType: 'MANUAL', name: 'Manual' },
{
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Record',
},
{
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Record',
},
{
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Record',
},
{
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
},
{ nodeType: 'action', actionType: 'CODE', name: 'Code' },
] satisfies WorkflowDiagramStepNodeData[],
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [
CatalogDecorator,
(Story) => {
return (
<ReactFlowProvider>
<Story />
</ReactFlowProvider>
);
},
],
decorators: [CatalogDecorator, ReactflowDecorator],
};
export const AllSelected: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'default',
selected: true,
},
parameters: {
@ -103,48 +99,131 @@ export const AllSelected: CatalogStory<
dimensions: [
{
name: 'step type',
values: [
{
nodeType: 'trigger',
triggerType: 'DATABASE_EVENT',
name: 'Record is Created',
},
{ nodeType: 'trigger', triggerType: 'MANUAL', name: 'Manual' },
{
nodeType: 'action',
actionType: 'CREATE_RECORD',
name: 'Create Record',
},
{
nodeType: 'action',
actionType: 'UPDATE_RECORD',
name: 'Update Record',
},
{
nodeType: 'action',
actionType: 'DELETE_RECORD',
name: 'Delete Record',
},
{
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Send Email',
},
{ nodeType: 'action', actionType: 'CODE', name: 'Code' },
] satisfies WorkflowDiagramStepNodeData[],
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [
CatalogDecorator,
(Story) => {
return (
<ReactFlowProvider>
<Story />
</ReactFlowProvider>
);
},
],
decorators: [CatalogDecorator, ReactflowDecorator],
};
export const AllSuccess: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'success',
},
parameters: {
msw: graphqlMocks,
catalog: {
options: {
elementContainer: {
width: 250,
style: { position: 'relative' },
},
},
dimensions: [
{
name: 'step type',
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [CatalogDecorator, ReactflowDecorator],
};
export const AllSuccessSelected: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'success',
selected: true,
},
parameters: {
msw: graphqlMocks,
catalog: {
options: {
elementContainer: {
width: 250,
style: { position: 'relative' },
className: 'selectable selected',
},
},
dimensions: [
{
name: 'step type',
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [CatalogDecorator, ReactflowDecorator],
};
export const AllFailure: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'failure',
},
parameters: {
msw: graphqlMocks,
catalog: {
options: {
elementContainer: {
width: 250,
style: { position: 'relative' },
},
},
dimensions: [
{
name: 'step type',
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [CatalogDecorator, ReactflowDecorator],
};
export const AllFailureSelected: CatalogStory<
Story,
typeof WorkflowDiagramStepNodeEditableContent
> = {
args: {
onDelete: fn(),
variant: 'failure',
selected: true,
},
parameters: {
msw: graphqlMocks,
catalog: {
options: {
elementContainer: {
width: 250,
style: { position: 'relative' },
className: 'selectable selected',
},
},
dimensions: [
{
name: 'step type',
values: ALL_STEPS,
props: (data: WorkflowDiagramStepNodeData) => ({ data }),
},
],
},
},
decorators: [CatalogDecorator, ReactflowDecorator],
};

View File

@ -0,0 +1,5 @@
export type WorkflowDiagramNodeVariant =
| 'default'
| 'success'
| 'failure'
| 'empty';

View File

@ -0,0 +1,10 @@
import { Decorator } from '@storybook/react';
import { ReactFlowProvider } from '@xyflow/react';
export const ReactflowDecorator: Decorator = (Story) => {
return (
<ReactFlowProvider>
<Story />
</ReactFlowProvider>
);
};

View File

@ -0,0 +1,8 @@
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
export const ADAPTIVE_COLORS_DARK = {
turquoise1: THEME_COMMON.color.turquoise80,
turquoise2: THEME_COMMON.color.turquoise70,
turquoise3: THEME_COMMON.color.turquoise60,
turquoise4: THEME_COMMON.color.turquoise50,
};

View File

@ -0,0 +1,8 @@
import { THEME_COMMON } from '@ui/theme/constants/ThemeCommon';
export const ADAPTIVE_COLORS_LIGHT = {
turquoise1: THEME_COMMON.color.turquoise10,
turquoise2: THEME_COMMON.color.turquoise20,
turquoise3: THEME_COMMON.color.turquoise30,
turquoise4: THEME_COMMON.color.turquoise40,
};

View File

@ -1,3 +1,4 @@
import { ADAPTIVE_COLORS_DARK } from '@ui/theme/constants/AdaptiveColorsDark';
import { BLUR_DARK } from '@ui/theme/constants/BlurDark';
import { ILLUSTRATION_ICON_DARK } from '@ui/theme/constants/IllustrationIconDark';
import { SNACK_BAR_DARK, ThemeType } from '..';
@ -24,5 +25,6 @@ export const THEME_DARK: ThemeType = {
tag: TAG_DARK,
code: CODE_DARK,
IllustrationIcon: ILLUSTRATION_ICON_DARK,
adaptiveColors: ADAPTIVE_COLORS_DARK,
},
};

View File

@ -1,3 +1,4 @@
import { ADAPTIVE_COLORS_LIGHT } from '@ui/theme/constants/AdaptiveColorsLight';
import { BLUR_LIGHT } from '@ui/theme/constants/BlurLight';
import { ILLUSTRATION_ICON_LIGHT } from '@ui/theme/constants/IllustrationIconLight';
import { SNACK_BAR_LIGHT } from '@ui/theme/constants/SnackBarLight';
@ -24,5 +25,6 @@ export const THEME_LIGHT = {
tag: TAG_LIGHT,
code: CODE_LIGHT,
IllustrationIcon: ILLUSTRATION_ICON_LIGHT,
adaptiveColors: ADAPTIVE_COLORS_LIGHT,
},
};

View File

@ -1,5 +1,7 @@
export * from './constants/AccentDark';
export * from './constants/AccentLight';
export * from './constants/AdaptiveColorsDark';
export * from './constants/AdaptiveColorsLight';
export * from './constants/Animation';
export * from './constants/BackgroundDark';
export * from './constants/BackgroundLight';