Improve Form Layout + add drag and drop (#11901)
https://github.com/user-attachments/assets/cf542921-9354-4f7b-b6e8-061ebcaa9a9b Closes https://github.com/twentyhq/core-team-issues/issues/887 Closes https://github.com/twentyhq/core-team-issues/issues/889 Closes https://github.com/twentyhq/core-team-issues/issues/890
This commit is contained in:
committed by
GitHub
parent
ca6e979ead
commit
a4656b415c
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user