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:
@ -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' &&
|
||||
@ -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>
|
||||
@ -3,4 +3,5 @@ export type VariablePickerComponent = React.FC<{
|
||||
disabled?: boolean;
|
||||
multiline?: boolean;
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
objectNameSingularToSelect?: string;
|
||||
}>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
export type WorkflowFormFieldType =
|
||||
| FieldMetadataType.TEXT
|
||||
| FieldMetadataType.NUMBER
|
||||
| 'RECORD';
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
}, {});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
export type WorkflowFormFieldType =
|
||||
| FieldMetadataType.TEXT
|
||||
| FieldMetadataType.NUMBER
|
||||
| 'RECORD';
|
||||
Reference in New Issue
Block a user