Add record picker in form action (#11331)

Record picker becomes a form field that could be used in another context
than workflows.

Settings
<img width="488" alt="Capture d’écran 2025-04-02 à 10 55 53"
src="https://github.com/user-attachments/assets/a9fc09ff-28cd-4ede-8aaa-af1e986cda8e"
/>

Execution
<img width="936" alt="Capture d’écran 2025-04-02 à 10 57 36"
src="https://github.com/user-attachments/assets/d796aeeb-cae1-4e59-b388-5b8d08739ea8"
/>
This commit is contained in:
Thomas Trompette
2025-04-02 17:08:33 +02:00
committed by GitHub
parent 2bc9691021
commit 7488c6727a
21 changed files with 400 additions and 154 deletions

View File

@ -1,11 +1,11 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import {
RecordId,
Variable,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
} from '@/object-record/record-field/form-types/components/FormSingleRecordPicker';
import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
const StyledRecordChip = styled(RecordChip)`
@ -18,7 +18,7 @@ const StyledPlaceholder = styled.div`
margin: ${({ theme }) => theme.spacing(2)};
`;
type WorkflowSingleRecordFieldChipProps = {
type FormSingleRecordFieldChipProps = {
draftValue:
| {
type: 'static';
@ -34,13 +34,13 @@ type WorkflowSingleRecordFieldChipProps = {
disabled?: boolean;
};
export const WorkflowSingleRecordFieldChip = ({
export const FormSingleRecordFieldChip = ({
draftValue,
selectedRecord,
objectNameSingular,
onRemove,
disabled,
}: WorkflowSingleRecordFieldChipProps) => {
}: FormSingleRecordFieldChipProps) => {
if (
!!draftValue &&
draftValue.type === 'variable' &&

View File

@ -2,6 +2,8 @@ import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormFieldInputInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputInputContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { FormSingleRecordFieldChip } from '@/object-record/record-field/form-types/components/FormSingleRecordFieldChip';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { SingleRecordPicker } from '@/object-record/record-picker/single-record-picker/components/SingleRecordPicker';
import { singleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSearchFilterComponentState';
import { singleRecordPickerSelectedIdComponentState } from '@/object-record/record-picker/single-record-picker/states/singleRecordPickerSelectedIdComponentState';
@ -12,13 +14,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { WorkflowSingleRecordFieldChip } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordFieldChip';
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback } from 'react';
import { IconChevronDown, IconForbid, LightIconButton } from 'twenty-ui';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { IconChevronDown, IconForbid, LightIconButton } from 'twenty-ui';
const StyledFormSelectContainer = styled(FormFieldInputInputContainer)`
justify-content: space-between;
@ -26,27 +25,10 @@ const StyledFormSelectContainer = styled(FormFieldInputInputContainer)`
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledSearchVariablesDropdownContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
${({ theme }) => css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme }) => css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export type RecordId = string;
export type Variable = string;
type WorkflowSingleRecordPickerValue =
type FormSingleRecordPickerValue =
| {
type: 'static';
value: RecordId;
@ -56,33 +38,36 @@ type WorkflowSingleRecordPickerValue =
value: Variable;
};
export type WorkflowSingleRecordPickerProps = {
export type FormSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
onChange: (value: RecordId | Variable) => void;
objectNameSingular: string;
disabled?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowSingleRecordPicker = ({
export const FormSingleRecordPicker = ({
label,
defaultValue,
objectNameSingular,
onChange,
disabled,
testId,
}: WorkflowSingleRecordPickerProps) => {
const draftValue: WorkflowSingleRecordPickerValue =
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
};
VariablePicker,
}: FormSingleRecordPickerProps) => {
const draftValue: FormSingleRecordPickerValue = isStandaloneVariableString(
defaultValue,
)
? {
type: 'variable',
value: defaultValue,
}
: {
type: 'static',
value: defaultValue || '',
};
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
@ -94,8 +79,8 @@ export const WorkflowSingleRecordPicker = ({
skip: !isValidUuid(defaultValue),
});
const dropdownId = `workflow-record-picker-${objectNameSingular}`;
const variablesDropdownId = `workflow-record-picker-${objectNameSingular}-variables`;
const dropdownId = `form-record-picker-${objectNameSingular}`;
const variablesDropdownId = `form-record-picker-${objectNameSingular}-variables`;
const { closeDropdown } = useDropdown(dropdownId);
@ -144,8 +129,10 @@ export const WorkflowSingleRecordPicker = ({
<FormFieldInputContainer testId={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledFormSelectContainer hasRightElement={!disabled}>
<WorkflowSingleRecordFieldChip
<StyledFormSelectContainer
hasRightElement={isDefined(VariablePicker) && !disabled}
>
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingular}
@ -182,15 +169,13 @@ export const WorkflowSingleRecordPicker = ({
</DropdownScope>
)}
</StyledFormSelectContainer>
{!disabled && (
<StyledSearchVariablesDropdownContainer>
<WorkflowVariablesDropdown
inputId={variablesDropdownId}
onVariableSelect={handleVariableTagInsert}
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
{isDefined(VariablePicker) && !disabled && (
<VariablePicker
inputId={variablesDropdownId}
disabled={disabled}
onVariableSelect={handleVariableTagInsert}
objectNameSingularToSelect={objectNameSingular}
/>
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>

View File

@ -3,4 +3,5 @@ export type VariablePickerComponent = React.FC<{
disabled?: boolean;
multiline?: boolean;
onVariableSelect: (variableName: string) => void;
objectNameSingularToSelect?: string;
}>;

View File

@ -92,6 +92,7 @@ export const workflowFormActionSettingsSchema =
type: z.union([
z.literal(FieldMetadataType.TEXT),
z.literal(FieldMetadataType.NUMBER),
z.literal('RECORD'),
]),
placeholder: z.string().optional(),
settings: z.record(z.any()).optional(),

View File

@ -1,14 +1,15 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormSingleRecordPicker } from '@/object-record/record-field/form-types/components/FormSingleRecordPicker';
import { Select } from '@/ui/input/components/Select';
import { WorkflowDeleteRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
import { useEffect, useState } from 'react';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, SelectOption, useIcons } from 'twenty-ui';
import { JsonValue } from 'type-fest';
@ -154,7 +155,7 @@ export const WorkflowEditActionDeleteRecord = ({
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
<FormSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
@ -163,6 +164,7 @@ export const WorkflowEditActionDeleteRecord = ({
defaultValue={formData.objectRecordId}
testId="workflow-edit-action-record-delete-object-record-id"
disabled={isFormDisabled}
VariablePicker={WorkflowVariablePicker}
/>
</WorkflowStepBody>
</>

View File

@ -6,9 +6,9 @@ import { useEffect, useState } from 'react';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/form-types/components/FormMultiSelectFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/form-types/components/FormSingleRecordPicker';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { WorkflowSingleRecordPicker } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowSingleRecordPicker';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
@ -208,7 +208,7 @@ export const WorkflowEditActionUpdateRecord = ({
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
<FormSingleRecordPicker
testId="workflow-edit-action-record-update-object-record-id"
label="Record"
onChange={(objectRecordId) =>
@ -217,6 +217,7 @@ export const WorkflowEditActionUpdateRecord = ({
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
disabled={isFormDisabled}
VariablePicker={WorkflowVariablePicker}
/>
<FormMultiSelectFieldInput

View File

@ -3,6 +3,7 @@ import { FormSelectFieldInput } from '@/object-record/record-field/form-types/co
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowFormFieldSettingsByType } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsByType';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { WorkflowFormFieldType } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormFieldType';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
@ -13,6 +14,7 @@ import {
IconSettingsAutomation,
IconX,
IllustrationIconNumbers,
IllustrationIconOneToMany,
IllustrationIconText,
LightIconButton,
} from 'twenty-ui';
@ -95,6 +97,7 @@ export const WorkflowEditActionFormFieldSettings = ({
</StyledTitleContainer>
<StyledCloseButtonContainer>
<LightIconButton
testId="close-button"
Icon={IconX}
size="small"
accent="secondary"
@ -106,35 +109,47 @@ export const WorkflowEditActionFormFieldSettings = ({
<FormFieldInputContainer>
<InputLabel>Type</InputLabel>
<FormSelectFieldInput
options={[
{
label: getDefaultFormFieldSettings(FieldMetadataType.TEXT)
.label,
value: FieldMetadataType.TEXT,
Icon: IllustrationIconText,
},
{
label: getDefaultFormFieldSettings(FieldMetadataType.NUMBER)
.label,
value: FieldMetadataType.NUMBER,
Icon: IllustrationIconNumbers,
},
]}
options={
[
{
label: getDefaultFormFieldSettings(FieldMetadataType.TEXT)
.label,
value: FieldMetadataType.TEXT,
Icon: IllustrationIconText,
},
{
label: getDefaultFormFieldSettings(FieldMetadataType.NUMBER)
.label,
value: FieldMetadataType.NUMBER,
Icon: IllustrationIconNumbers,
},
{
label: 'Record Picker',
value: 'RECORD',
Icon: IllustrationIconOneToMany,
},
] satisfies {
label: string;
value: WorkflowFormFieldType;
Icon: React.ElementType;
}[]
}
onChange={(newType: string | null) => {
if (newType === null) {
return;
}
const type = newType as
| FieldMetadataType.TEXT
| FieldMetadataType.NUMBER;
const { label, placeholder } = getDefaultFormFieldSettings(type);
const type = newType as WorkflowFormFieldType;
const { name, label, placeholder, settings } =
getDefaultFormFieldSettings(type);
onChange({
...field,
type,
name,
label,
placeholder,
settings,
});
}}
defaultValue={field.type}

View File

@ -1,6 +1,7 @@
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FormFieldInput } from '@/object-record/record-field/components/FormFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/form-types/components/FormSingleRecordPicker';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { RightDrawerFooter } from '@/ui/layout/right-drawer/components/RightDrawerFooter';
import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/WorkflowStepContext';
@ -114,28 +115,58 @@ export const WorkflowEditActionFormFiller = ({
disabled
/>
<WorkflowStepBody>
{formData.map((field) => (
<FormFieldInput
key={field.id}
field={{
label: field.label,
type: field.type,
metadata: {} as FieldMetadata,
}}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
defaultValue={field.value ?? ''}
readonly={actionOptions.readonly}
placeholder={
field.placeholder ??
getDefaultFormFieldSettings(field.type).placeholder
{formData.map((field) => {
if (field.type === 'RECORD') {
const objectNameSingular = field.settings?.objectName;
if (!isDefined(objectNameSingular)) {
return null;
}
/>
))}
const recordId = field.value?.id;
return (
<FormSingleRecordPicker
key={field.id}
label={field.label}
defaultValue={recordId}
onChange={(recordId) => {
onFieldUpdate({
fieldId: field.id,
value: {
id: recordId,
},
});
}}
objectNameSingular={objectNameSingular}
disabled={actionOptions.readonly}
/>
);
}
return (
<FormFieldInput
key={field.id}
field={{
label: field.label,
type: field.type,
metadata: {} as FieldMetadata,
}}
onChange={(value) => {
onFieldUpdate({
fieldId: field.id,
value,
});
}}
defaultValue={field.value ?? ''}
readonly={actionOptions.readonly}
placeholder={
field.placeholder ??
getDefaultFormFieldSettings(field.type).placeholder
}
/>
);
})}
</WorkflowStepBody>
{!actionOptions.readonly && (
<RightDrawerFooter

View File

@ -1,15 +1,16 @@
import { WorkflowFormFieldSettingsRecordPicker } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormFieldSettingsRecordPicker';
import { WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { WorkflowFormFieldSettingsNumber } from './WorkflowFormFieldSettingsNumber';
import { WorkflowFormFieldSettingsText } from './WorkflowFormFieldSettingsText';
import { assertUnreachable } from 'twenty-shared/utils';
import { FieldMetadataType } from 'twenty-shared/types';
export const WorkflowFormFieldSettingsByType = ({
field,
onChange,
}: {
field: WorkflowFormActionField;
onChange: (fieldName: string, value: string | null) => void;
onChange: (fieldName: string, value: unknown) => void;
}) => {
switch (field.type) {
case FieldMetadataType.TEXT:
@ -32,6 +33,16 @@ export const WorkflowFormFieldSettingsByType = ({
}}
/>
);
case 'RECORD':
return (
<WorkflowFormFieldSettingsRecordPicker
label={field.label}
settings={field.settings}
onChange={(fieldName, value) => {
onChange(fieldName, value);
}}
/>
);
default:
return assertUnreachable(field.type, 'Unknown form field type');
}

View File

@ -0,0 +1,69 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormFieldInputContainer } from '@/object-record/record-field/form-types/components/FormFieldInputContainer';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import styled from '@emotion/styled';
import { SelectOption, useIcons } from 'twenty-ui';
type WorkflowFormFieldSettingsRecordPickerProps = {
label?: string;
settings?: Record<string, any>;
onChange: (fieldName: string, value: unknown) => void;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
export const WorkflowFormFieldSettingsRecordPicker = ({
label,
settings,
onChange,
}: WorkflowFormFieldSettingsRecordPickerProps) => {
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
return (
<StyledContainer>
<FormFieldInputContainer>
<Select
dropdownId="workflow-form-field-settings-record-picker-object-name"
label="Object"
fullWidth
value={settings?.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedObjectName) => {
onChange('settings', {
...settings,
objectName: updatedObjectName,
});
}}
withSearchInput
/>
</FormFieldInputContainer>
<FormFieldInputContainer>
<InputLabel>Label</InputLabel>
<FormTextFieldInput
onChange={(newLabel: string | null) => {
onChange('label', newLabel);
}}
defaultValue={label}
placeholder={getDefaultFormFieldSettings('RECORD').label}
/>
</FormFieldInputContainer>
</StyledContainer>
);
};

View File

@ -1,11 +1,12 @@
import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowEditActionFormFieldSettings } from '../WorkflowEditActionFormFieldSettings';
import { FieldMetadataType } from 'twenty-shared/types';
const meta: Meta<typeof WorkflowEditActionFormFieldSettings> = {
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFieldSettings',
@ -14,6 +15,7 @@ const meta: Meta<typeof WorkflowEditActionFormFieldSettings> = {
WorkflowStepActionDrawerDecorator,
ComponentDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
],
};
@ -90,3 +92,32 @@ export const NumberFieldSettings: Story = {
expect(args.onClose).toHaveBeenCalled();
},
};
export const SingleRecordFieldSettings: Story = {
args: {
field: {
id: 'field-3',
name: 'record',
label: 'Record',
type: 'RECORD',
settings: {
objectName: 'company',
},
},
onClose: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const typeSelect = await canvas.findByText('Record');
expect(typeSelect).toBeVisible();
const objectSelect = await canvas.findByText('Companies');
expect(objectSelect).toBeVisible();
const closeButton = await canvas.findByTestId('close-button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},
};

View File

@ -54,6 +54,16 @@ const mockAction: WorkflowFormAction = {
placeholder: 'Enter number',
settings: {},
},
{
id: 'field-3',
name: 'record',
label: 'Record',
type: 'RECORD',
placeholder: 'Select a record',
settings: {
objectName: 'company',
},
},
],
outputSchema: {},
errorHandlingOptions: {
@ -78,6 +88,9 @@ export const Default: Story = {
const numberField = await canvas.findByText('Number Field');
expect(numberField).toBeVisible();
const recordField = await canvas.findByText('Record');
expect(recordField).toBeVisible();
},
};

View File

@ -1,12 +1,11 @@
import { JsonValue } from 'type-fest';
import { FieldMetadataType } from 'twenty-shared/types';
import { WorkflowFormFieldType } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormFieldType';
export type WorkflowFormActionField = {
id: string;
name: string;
label: string;
type: FieldMetadataType.TEXT | FieldMetadataType.NUMBER;
type: WorkflowFormFieldType;
placeholder?: string;
settings?: Record<string, unknown>;
value?: JsonValue;
settings?: Record<string, any>;
value?: any;
};

View File

@ -0,0 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type WorkflowFormFieldType =
| FieldMetadataType.TEXT
| FieldMetadataType.NUMBER
| 'RECORD';

View File

@ -1,7 +1,9 @@
import { v4 } from 'uuid';
import { WorkflowFormFieldType } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { v4 } from 'uuid';
export const getDefaultFormFieldSettings = (type: FieldMetadataType) => {
export const getDefaultFormFieldSettings = (type: WorkflowFormFieldType) => {
switch (type) {
case FieldMetadataType.TEXT:
return {
@ -17,12 +19,17 @@ export const getDefaultFormFieldSettings = (type: FieldMetadataType) => {
label: 'Number',
placeholder: '1000',
};
default:
case 'RECORD':
return {
id: v4(),
name: '',
label: type.charAt(0).toUpperCase() + type.slice(1),
placeholder: 'Enter your value',
name: 'record',
label: 'Record',
placeholder: 'Select a record',
settings: {
objectName: 'company',
},
};
default:
assertUnreachable(type);
}
};

View File

@ -41,6 +41,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
disabled,
multiline,
onVariableSelect,
objectNameSingularToSelect,
}) => {
return (
<StyledSearchVariablesDropdownContainer
@ -51,6 +52,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
inputId={inputId}
onVariableSelect={onVariableSelect}
disabled={disabled}
objectNameSingularToSelect={objectNameSingularToSelect}
/>
</StyledSearchVariablesDropdownContainer>
);

View File

@ -1,10 +1,24 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { mockObjectMetadataItemsWithFieldMaps } from 'src/engine/core-modules/search/__mocks__/mockObjectMetadataItemsWithFieldMaps';
import { generateFakeFormResponse } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-form-response';
import { FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
const companyMockObjectMetadataItem = mockObjectMetadataItemsWithFieldMaps.find(
(item) => item.nameSingular === 'company',
)!;
describe('generateFakeFormResponse', () => {
it('should generate fake responses for a form schema', () => {
const schema = [
let objectMetadataRepository;
beforeEach(() => {
objectMetadataRepository = {
findOneOrFail: jest.fn().mockResolvedValue(companyMockObjectMetadataItem),
};
});
it('should generate fake responses for a form schema', async () => {
const schema: FormFieldMetadata[] = [
{
id: '96939213-49ac-4dee-949d-56e6c7be98e6',
name: 'name',
@ -19,34 +33,22 @@ describe('generateFakeFormResponse', () => {
},
{
id: '96939213-49ac-4dee-949d-56e6c7be98e8',
name: 'email',
type: FieldMetadataType.EMAILS,
label: 'Email',
name: 'company',
type: 'RECORD',
label: 'Company',
settings: {
objectName: 'company',
},
},
];
const result = generateFakeFormResponse(schema);
const result = await generateFakeFormResponse({
formMetadata: schema,
workspaceId: '1',
objectMetadataRepository,
});
expect(result).toEqual({
email: {
isLeaf: false,
label: 'Email',
value: {
additionalEmails: {
isLeaf: true,
label: ' Additional Emails',
type: FieldMetadataType.RAW_JSON,
value: null,
},
primaryEmail: {
isLeaf: true,
label: ' Primary Email',
type: FieldMetadataType.TEXT,
value: 'My text',
},
},
icon: undefined,
},
name: {
isLeaf: true,
label: 'Name',
@ -61,6 +63,22 @@ describe('generateFakeFormResponse', () => {
value: 20,
icon: undefined,
},
company: {
isLeaf: false,
label: 'Company',
value: {
_outputSchemaType: 'RECORD',
fields: {},
object: {
isLeaf: true,
label: 'Company',
fieldIdName: 'id',
icon: undefined,
nameSingular: 'company',
value: 'A company',
},
},
},
});
});
});

View File

@ -1,19 +1,58 @@
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
Leaf,
Node,
} from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { generateFakeField } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-field';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-object-record';
import { FormFieldMetadata } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
export const generateFakeFormResponse = (
formMetadata: FormFieldMetadata[],
): Record<string, Leaf | Node> => {
return formMetadata.reduce((acc, formFieldMetadata) => {
acc[formFieldMetadata.name] = generateFakeField({
type: formFieldMetadata.type,
label: formFieldMetadata.label,
});
export const generateFakeFormResponse = async ({
formMetadata,
workspaceId,
objectMetadataRepository,
}: {
formMetadata: FormFieldMetadata[];
workspaceId: string;
objectMetadataRepository: Repository<ObjectMetadataEntity>;
}): Promise<Record<string, Leaf | Node>> => {
const result = await Promise.all(
formMetadata.map(async (formFieldMetadata) => {
if (formFieldMetadata.type === 'RECORD') {
if (!formFieldMetadata?.settings?.objectName) {
return undefined;
}
return acc;
const objectMetadata = await objectMetadataRepository.findOneOrFail({
where: {
nameSingular: formFieldMetadata?.settings?.objectName,
workspaceId,
},
relations: ['fields'],
});
return {
[formFieldMetadata.name]: {
isLeaf: false,
label: formFieldMetadata.label,
value: generateFakeObjectRecord(objectMetadata),
},
};
} else {
return {
[formFieldMetadata.name]: generateFakeField({
type: formFieldMetadata.type,
label: formFieldMetadata.label,
}),
};
}
}),
);
return result.filter(isDefined).reduce((acc, curr) => {
return { ...acc, ...curr };
}, {});
};

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
@ -83,6 +83,8 @@ export class WorkflowSchemaWorkspaceService {
case WorkflowActionType.FORM:
return this.computeFormActionOutputSchema({
formMetadata: step.settings.input,
workspaceId,
objectMetadataRepository: this.objectMetadataRepository,
});
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default:
@ -182,11 +184,19 @@ export class WorkflowSchemaWorkspaceService {
return { success: { isLeaf: true, type: 'boolean', value: true } };
}
private computeFormActionOutputSchema({
private async computeFormActionOutputSchema({
formMetadata,
workspaceId,
objectMetadataRepository,
}: {
formMetadata: FormFieldMetadata[];
}): OutputSchema {
return generateFakeFormResponse(formMetadata);
workspaceId: string;
objectMetadataRepository: Repository<ObjectMetadataEntity>;
}): Promise<OutputSchema> {
return generateFakeFormResponse({
formMetadata,
workspaceId,
objectMetadataRepository,
});
}
}

View File

@ -1,12 +1,11 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { WorkflowFormFieldType } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-field-type.type';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type FormFieldMetadata = {
id: string;
name: string;
label: string;
type: FieldMetadataType;
type: WorkflowFormFieldType;
value?: any;
placeholder?: string;
settings?: Record<string, any>;

View File

@ -0,0 +1,6 @@
import { FieldMetadataType } from 'twenty-shared/types';
export type WorkflowFormFieldType =
| FieldMetadataType.TEXT
| FieldMetadataType.NUMBER
| 'RECORD';