Baptiste Devessier
2025-05-12 12:09:46 +02:00
committed by GitHub
parent ca6e979ead
commit a4656b415c
68 changed files with 631 additions and 182 deletions

View File

@ -2,6 +2,8 @@ import { FormFieldInputContainer } from '@/object-record/record-field/form-types
import { FormFieldInputInnerContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
@ -13,19 +15,21 @@ import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-ac
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { OnDragEndResponder } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useDebouncedCallback } from 'use-debounce';
import { isNonEmptyString } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronDown,
IconGripVertical,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
export type WorkflowEditActionFormBuilderProps = {
@ -42,10 +46,45 @@ export type WorkflowEditActionFormBuilderProps = {
type FormData = WorkflowFormActionField[];
const StyledRowContainer = styled.div`
const StyledWorkflowStepBody = styled(WorkflowStepBody)`
display: block;
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
const StyledFormFieldContainer = styled.div`
align-items: flex-end;
column-gap: ${({ theme }) => theme.spacing(1)};
display: grid;
grid-template-columns: 1fr 16px;
grid-template-areas:
'grip input delete'
'. settings .';
grid-template-columns: 24px 1fr 24px;
position: relative;
`;
const StyledDraggingIndicator = styled.div`
position: absolute;
inset: ${({ theme }) => theme.spacing(-2)};
top: ${({ theme }) => theme.spacing(-1)};
background-color: ${({ theme }) => theme.background.transparent.light};
`;
const StyledLightGripIconButton = styled(LightIconButton)`
grid-area: grip;
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledLightTrashIconButton = styled(LightIconButton)`
grid-area: delete;
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledFormFieldInputContainer = styled(FormFieldInputContainer)`
grid-area: input;
`;
const StyledOpenedSettingsContainer = styled.div`
grid-area: settings;
`;
const StyledFieldContainer = styled.div`
@ -71,22 +110,8 @@ const StyledPlaceholder = styled.div`
width: 100%;
`;
const StyledIconButtonContainer = styled.div`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
width: 24px;
cursor: pointer;
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledAddFieldButtonContainer = styled.div`
padding-inline: ${({ theme }) => theme.spacing(7)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
@ -100,10 +125,6 @@ const StyledAddFieldButtonContentContainer = styled.div`
width: 100%;
`;
const StyledLabelContainer = styled.div`
min-height: 17px;
`;
export const WorkflowEditActionFormBuilder = ({
action,
actionOptions,
@ -121,8 +142,11 @@ export const WorkflowEditActionFormBuilder = ({
const [selectedField, setSelectedField] = useState<string | null>(null);
const [hoveredField, setHoveredField] = useState<string | null>(null);
const isFieldSelected = (fieldName: string) => selectedField === fieldName;
const isFieldHovered = (fieldName: string) => hoveredField === fieldName;
const handleFieldClick = (fieldName: string) => {
if (actionOptions.readonly === true) {
return;
@ -149,6 +173,27 @@ export const WorkflowEditActionFormBuilder = ({
saveAction(updatedFormData);
};
const handleDragEnd: OnDragEndResponder = ({ source, destination }) => {
if (actionOptions.readonly === true) {
return;
}
const movedField = formData.at(source.index);
if (!isDefined(movedField) || !isDefined(destination)) {
return;
}
const copiedFormData = [...formData];
copiedFormData.splice(source.index, 1);
copiedFormData.splice(destination.index, 0, movedField);
setFormData(copiedFormData);
saveAction(copiedFormData);
};
const saveAction = useDebouncedCallback(async (formData: FormData) => {
if (actionOptions.readonly === true) {
return;
@ -188,121 +233,158 @@ export const WorkflowEditActionFormBuilder = ({
headerType={headerType}
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
{formData.map((field) => (
<FormFieldInputContainer key={field.id}>
<StyledLabelContainer>
<InputLabel>{field.label || ''}</InputLabel>
</StyledLabelContainer>
<StyledWorkflowStepBody>
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{formData.map((field, index) => (
<DraggableItem
key={field.id}
draggableId={field.id}
index={index}
isDragDisabled={actionOptions.readonly}
isInsideScrollableContainer
disableDraggingBackground
draggableComponentStyles={{
marginBottom: theme.spacing(4),
}}
itemComponent={({ isDragging }) => {
const showButtons =
!actionOptions.readonly &&
(isFieldSelected(field.id) ||
isFieldHovered(field.id) ||
isDragging);
<StyledRowContainer
onMouseEnter={() => setHoveredField(field.id)}
onMouseLeave={() => setHoveredField(null)}
>
return (
<StyledFormFieldContainer
key={field.id}
onMouseEnter={() => setHoveredField(field.id)}
onMouseLeave={() => setHoveredField(null)}
>
{isDragging && <StyledDraggingIndicator />}
{showButtons && (
<StyledLightGripIconButton
Icon={IconGripVertical}
aria-label={t`Reorder field`}
/>
)}
<StyledFormFieldInputContainer>
<InputLabel>{field.label || ''}</InputLabel>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
hasRightElement={false}
onClick={() => {
handleFieldClick(field.id);
}}
>
<StyledFieldContainer>
<StyledPlaceholder>
{isDefined(field.placeholder) &&
isNonEmptyString(field.placeholder)
? field.placeholder
: getDefaultFormFieldSettings(field.type)
.placeholder}
</StyledPlaceholder>
{field.type === 'RECORD' && (
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
)}
</StyledFieldContainer>
</FormFieldInputInnerContainer>
</FormFieldInputRowContainer>
</StyledFormFieldInputContainer>
{showButtons && (
<StyledLightTrashIconButton
Icon={IconTrash}
aria-label={t`Delete field`}
onClick={() => {
const updatedFormData = formData.filter(
(currentField) => currentField.id !== field.id,
);
setFormData(updatedFormData);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: updatedFormData,
},
});
}}
/>
)}
{isFieldSelected(field.id) && (
<StyledOpenedSettingsContainer>
<WorkflowEditActionFormFieldSettings
field={field}
onChange={onFieldUpdate}
onClose={() => {
setSelectedField(null);
}}
/>
</StyledOpenedSettingsContainer>
)}
</StyledFormFieldContainer>
);
}}
/>
))}
</>
}
/>
{!actionOptions.readonly && (
<StyledAddFieldButtonContainer>
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
hasRightElement={false}
onClick={() => {
handleFieldClick(field.id);
const { label, name } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
};
setFormData([...formData, newField]);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: [...action.settings.input, newField],
},
});
setSelectedField(newField.id);
}}
>
<StyledFieldContainer>
<StyledPlaceholder>
{isDefined(field.placeholder) &&
isNonEmptyString(field.placeholder)
? field.placeholder
: getDefaultFormFieldSettings(field.type).placeholder}
</StyledPlaceholder>
{field.type === 'RECORD' && (
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
)}
<StyledAddFieldButtonContentContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldButtonContentContainer>
</StyledFieldContainer>
</FormFieldInputInnerContainer>
</FormFieldInputRowContainer>
{!actionOptions.readonly &&
(isFieldSelected(field.id) || isFieldHovered(field.id)) && (
<StyledIconButtonContainer>
<IconTrash
size={theme.icon.size.md}
color={theme.font.color.secondary}
onClick={() => {
const updatedFormData = formData.filter(
(currentField) => currentField.id !== field.id,
);
setFormData(updatedFormData);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: updatedFormData,
},
});
}}
/>
</StyledIconButtonContainer>
)}
{isFieldSelected(field.id) && (
<WorkflowEditActionFormFieldSettings
field={field}
onChange={onFieldUpdate}
onClose={() => {
setSelectedField(null);
}}
/>
)}
</StyledRowContainer>
</FormFieldInputContainer>
))}
{!actionOptions.readonly && (
<StyledAddFieldButtonContainer>
<StyledRowContainer>
<FormFieldInputContainer>
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
hasRightElement={false}
onClick={() => {
const { label, name } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
};
setFormData([...formData, newField]);
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: [...action.settings.input, newField],
},
});
setSelectedField(newField.id);
}}
>
<StyledFieldContainer>
<StyledAddFieldButtonContentContainer>
<IconPlus size={theme.icon.size.sm} />
{t`Add Field`}
</StyledAddFieldButtonContentContainer>
</StyledFieldContainer>
</FormFieldInputInnerContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
</StyledRowContainer>
</FormFieldInputContainer>
</StyledAddFieldButtonContainer>
)}
</WorkflowStepBody>
</StyledWorkflowStepBody>
</>
);
};

View File

@ -1,14 +1,13 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, within } from '@storybook/test';
import { userEvent } from '@storybook/testing-library';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
@ -81,6 +80,72 @@ export const Default: Story = {
},
};
export const DeleteFields: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const companyInput = await canvas.findByText('Company');
await userEvent.hover(companyInput);
const deleteButton = await canvas.findByRole('button', {
name: 'Delete field',
});
await userEvent.click(deleteButton);
await waitFor(() => {
expect(canvas.queryByText('Company')).not.toBeInTheDocument();
});
await waitFor(() => {
const actionOptions = args.actionOptions as typeof args.actionOptions & {
readonly?: false;
};
expect(actionOptions.onActionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
settings: expect.objectContaining({
input: [
{
id: 'ed00b897-519f-44cd-8201-a6502a3a9dc9',
name: 'number',
type: FieldMetadataType.NUMBER,
label: 'Number',
placeholder: '1000',
settings: {},
},
],
}),
}),
);
});
},
};
export const OpenFieldSettings: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const companyInput = await canvas.findByText('Select a company');
await userEvent.click(companyInput);
const inputSettingsLabel = await canvas.findByText('Input settings');
expect(inputSettingsLabel).toBeVisible();
},
};
export const DisabledWithEmptyValues: Story = {
args: {
actionOptions: {