Support morph relations in workflow record nodes (#21403)
## Support morph (polymorphic) relations in workflow record nodes
Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person`
or `Company`) were not selectable in the workflow **Create / Update /
Upsert Record** nodes. This PR adds full support for setting them.
### What changed
**Frontend**
- `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so
morph fields appear in record forms.
- New `FormMorphRelationToOneFieldInput`: a polymorphic record picker
across the morph's target objects, storing a self-describing value `{
targetObjectMetadataId, id }`.
- Wired the morph branch into `FormFieldInput`.
**Backend**
- New `formatWorkflowRecordMorphRelationFields` util: resolves the form
value (stored under the base field name, e.g. `owner`) into the correct
per-target join column (`ownerCompanyId`), nulling siblings to keep
exactly one target referenced.
- Wired into the create / update / upsert workflow actions (update also
expands `fieldsToUpdate` to the concrete join columns).
### Permissions handling
- The picker's search is scoped to only the morph targets the user can
read (`canReadObjectRecords`), so it no longer breaks when a target
object is inaccessible.
- If an existing value points to an object the user can't read, the
field shows the reused **"Not shared"** lock display instead of an empty
field, while remaining editable when other targets are readable.
### Notes
- No data schema / migration changes — reuses the existing per-target
morph columns and stores the selection in the existing workflow step
JSON settings.
<img width="607" height="717" alt="Screenshot 2026-06-10 at 14 57 40"
src="https://github.com/user-attachments/assets/496442a1-04a5-40f8-8b56-b28e38b00d5a"
/>
Also handles the case where the selected record is not readable
<img width="596" height="737" alt="image"
src="https://github.com/user-attachments/assets/c5ffb94e-3838-4db5-853e-f8e490331f23"
/>
This commit is contained in:
+15
@@ -8,6 +8,10 @@ import { FormEmailsFieldInput } from '@/object-record/record-field/ui/form-types
|
||||
import { FormFilesFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFilesFieldInput';
|
||||
import { FormFullNameFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFullNameFieldInput';
|
||||
import { FormLinksFieldInput } from '@/object-record/record-field/ui/form-types/components/FormLinksFieldInput';
|
||||
import {
|
||||
FormMorphRelationToOneFieldInput,
|
||||
type FormMorphRelationToOneValue,
|
||||
} from '@/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput';
|
||||
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
|
||||
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
|
||||
import { FormPhoneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormPhoneFieldInput';
|
||||
@@ -43,6 +47,7 @@ import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFi
|
||||
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
|
||||
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
|
||||
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
|
||||
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect';
|
||||
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
|
||||
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
|
||||
@@ -241,6 +246,16 @@ export const FormFieldInput = ({
|
||||
VariablePicker={VariablePicker}
|
||||
readonly={readonly}
|
||||
/>
|
||||
) : isFieldMorphRelationManyToOne(field) ? (
|
||||
<FormMorphRelationToOneFieldInput
|
||||
label={field.label}
|
||||
morphRelations={field.metadata.morphRelations}
|
||||
defaultValue={defaultValue as FormMorphRelationToOneValue}
|
||||
onClear={onClear}
|
||||
onChange={onChange}
|
||||
VariablePicker={VariablePicker}
|
||||
readonly={readonly}
|
||||
/>
|
||||
) : isFieldArray(field) ? (
|
||||
<FormArrayFieldInput
|
||||
label={field.label}
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
|
||||
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
|
||||
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
|
||||
import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder';
|
||||
import {
|
||||
FormSingleRecordPicker,
|
||||
type RecordId,
|
||||
type Variable,
|
||||
} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isString } from '@sniptt/guards';
|
||||
import { useId } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
|
||||
const StyledReadonlyContainer = styled.div`
|
||||
cursor: default;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledForbiddenFieldDisplayContainer = styled.div`
|
||||
display: flex;
|
||||
margin: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export type FormMorphRelationToOneValue =
|
||||
| {
|
||||
targetObjectMetadataId: string;
|
||||
id: string;
|
||||
}
|
||||
| Variable
|
||||
| null;
|
||||
|
||||
type FormMorphRelationToOneFieldInputProps = {
|
||||
label?: string;
|
||||
morphRelations: FieldMetadataItemRelation[];
|
||||
defaultValue?: FormMorphRelationToOneValue;
|
||||
onChange: (value: JsonValue) => void;
|
||||
onClear?: () => void;
|
||||
readonly?: boolean;
|
||||
testId?: string;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
};
|
||||
|
||||
export const FormMorphRelationToOneFieldInput = ({
|
||||
label,
|
||||
morphRelations,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onClear,
|
||||
readonly,
|
||||
testId,
|
||||
VariablePicker,
|
||||
}: FormMorphRelationToOneFieldInputProps) => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
||||
|
||||
const componentId = useId();
|
||||
|
||||
const readableObjectNameSingulars = [
|
||||
...new Set(
|
||||
morphRelations
|
||||
.filter(
|
||||
(morphRelation) =>
|
||||
objectPermissionsByObjectMetadataId[
|
||||
morphRelation.targetObjectMetadata.id
|
||||
]?.canReadObjectRecords === true,
|
||||
)
|
||||
.map(
|
||||
(morphRelation) => morphRelation.targetObjectMetadata.nameSingular,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
const selectedMorphValue =
|
||||
isDefined(defaultValue) && !isString(defaultValue) ? defaultValue : null;
|
||||
|
||||
const recordIdOrVariable: RecordId | Variable | null | undefined = isString(
|
||||
defaultValue,
|
||||
)
|
||||
? defaultValue
|
||||
: isDefined(defaultValue)
|
||||
? defaultValue.id
|
||||
: defaultValue;
|
||||
|
||||
const selectedTargetIsReadable =
|
||||
!isDefined(selectedMorphValue) ||
|
||||
objectPermissionsByObjectMetadataId[
|
||||
selectedMorphValue.targetObjectMetadataId
|
||||
]?.canReadObjectRecords === true;
|
||||
|
||||
const hasForbiddenSelectedRecord =
|
||||
isDefined(selectedMorphValue) && !selectedTargetIsReadable;
|
||||
|
||||
const selectedObjectMetadataItem = isDefined(selectedMorphValue)
|
||||
? objectMetadataItems.find(
|
||||
(objectMetadataItem) =>
|
||||
objectMetadataItem.id === selectedMorphValue.targetObjectMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const selectedObjectNameSingular =
|
||||
selectedObjectMetadataItem?.nameSingular ?? readableObjectNameSingulars[0];
|
||||
|
||||
if (hasForbiddenSelectedRecord) {
|
||||
return (
|
||||
<FormFieldInputContainer data-testid={testId}>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
<FormFieldInputRowContainer>
|
||||
<StyledReadonlyContainer>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={false}
|
||||
>
|
||||
<StyledForbiddenFieldDisplayContainer>
|
||||
<ForbiddenFieldDisplay />
|
||||
</StyledForbiddenFieldDisplayContainer>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledReadonlyContainer>
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (readableObjectNameSingulars.length === 0) {
|
||||
return (
|
||||
<FormFieldInputContainer data-testid={testId}>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
<FormFieldInputRowContainer>
|
||||
<StyledReadonlyContainer>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId={componentId}
|
||||
hasRightElement={false}
|
||||
>
|
||||
<FormFieldPlaceholder>{t`No record`}</FormFieldPlaceholder>
|
||||
</FormFieldInputInnerContainer>
|
||||
</StyledReadonlyContainer>
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSingleRecordPicker
|
||||
label={label}
|
||||
testId={testId}
|
||||
defaultValue={recordIdOrVariable}
|
||||
objectNameSingulars={readableObjectNameSingulars}
|
||||
selectedObjectNameSingular={selectedObjectNameSingular}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
onMorphItemSelected={(selectedMorphItem) =>
|
||||
onChange({
|
||||
targetObjectMetadataId: selectedMorphItem.objectMetadataId,
|
||||
id: selectedMorphItem.recordId,
|
||||
})
|
||||
}
|
||||
disabled={readonly}
|
||||
VariablePicker={VariablePicker}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+15
-3
@@ -60,6 +60,10 @@ export type FormSingleRecordPickerProps = {
|
||||
onClear?: () => void;
|
||||
onCreate?: (searchInput?: string) => void | Promise<void>;
|
||||
objectNameSingulars: string[];
|
||||
selectedObjectNameSingular?: string;
|
||||
onMorphItemSelected?: (
|
||||
selectedMorphItem: RecordPickerPickableMorphItem,
|
||||
) => void;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
@@ -69,8 +73,10 @@ export const FormSingleRecordPicker = ({
|
||||
label,
|
||||
defaultValue,
|
||||
objectNameSingulars,
|
||||
selectedObjectNameSingular,
|
||||
onChange,
|
||||
onClear,
|
||||
onMorphItemSelected,
|
||||
onCreate,
|
||||
disabled,
|
||||
testId,
|
||||
@@ -78,6 +84,9 @@ export const FormSingleRecordPicker = ({
|
||||
}: FormSingleRecordPickerProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const resolvedObjectNameSingular =
|
||||
selectedObjectNameSingular ?? objectNameSingulars[0];
|
||||
|
||||
const draftValue: FormSingleRecordPickerValue =
|
||||
defaultValue === null
|
||||
? { type: 'no-record', value: null }
|
||||
@@ -97,7 +106,7 @@ export const FormSingleRecordPicker = ({
|
||||
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
|
||||
? defaultValue
|
||||
: '',
|
||||
objectNameSingular: objectNameSingulars[0],
|
||||
objectNameSingular: resolvedObjectNameSingular,
|
||||
withSoftDeleted: true,
|
||||
skip: !isDefined(defaultValue) || !isValidUuid(defaultValue),
|
||||
});
|
||||
@@ -133,6 +142,8 @@ export const FormSingleRecordPicker = ({
|
||||
|
||||
if (defaultValue === selectedMorphItem.recordId) {
|
||||
onClear?.();
|
||||
} else if (isDefined(onMorphItemSelected)) {
|
||||
onMorphItemSelected(selectedMorphItem);
|
||||
} else {
|
||||
onChange(selectedMorphItem.recordId);
|
||||
}
|
||||
@@ -185,7 +196,7 @@ export const FormSingleRecordPicker = ({
|
||||
<FormSingleRecordFieldChip
|
||||
draftValue={draftValue}
|
||||
selectedRecord={selectedRecord}
|
||||
objectNameSingular={objectNameSingulars[0]}
|
||||
objectNameSingular={resolvedObjectNameSingular}
|
||||
onRemove={handleUnlinkVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -212,7 +223,7 @@ export const FormSingleRecordPicker = ({
|
||||
<FormSingleRecordFieldChip
|
||||
draftValue={draftValue}
|
||||
selectedRecord={selectedRecord}
|
||||
objectNameSingular={objectNameSingulars[0]}
|
||||
objectNameSingular={resolvedObjectNameSingular}
|
||||
onRemove={handleUnlinkVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -248,6 +259,7 @@ export const FormSingleRecordPicker = ({
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
shouldDisplayRecordObjects={true}
|
||||
shouldDisplayRecordFields={false}
|
||||
objectNameSingularsToSelect={objectNameSingulars}
|
||||
/>
|
||||
)}
|
||||
</FormFieldInputRowContainer>
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ export type VariablePickerComponent = React.FC<{
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
shouldDisplayRecordObjects?: boolean;
|
||||
shouldDisplayRecordFields?: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
}>;
|
||||
|
||||
+12
@@ -166,6 +166,15 @@ export const WorkflowEditActionCreateRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof CreateRecordFormData) => {
|
||||
const newFormData: CreateRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (formData: CreateRecordFormData) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@@ -242,6 +251,9 @@ export const WorkflowEditActionCreateRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldDefinition.metadata.fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldDefinition.metadata.fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+12
@@ -80,6 +80,15 @@ export const WorkflowEditActionUpdateRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof UpdateRecordFormData) => {
|
||||
const newFormData: UpdateRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === formData.objectNameSingular,
|
||||
);
|
||||
@@ -229,6 +238,9 @@ export const WorkflowEditActionUpdateRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+15
@@ -183,6 +183,15 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof UpsertRecordFormData) => {
|
||||
const newFormData: UpsertRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (formData: UpsertRecordFormData) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@@ -269,6 +278,9 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
onChange={(recordId) => {
|
||||
handleFieldChange('id', recordId);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear('id');
|
||||
}}
|
||||
objectNameSingulars={
|
||||
isDefined(objectNameSingular) ? [objectNameSingular] : []
|
||||
}
|
||||
@@ -299,6 +311,9 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldDefinition.metadata.fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldDefinition.metadata.fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -22,6 +22,7 @@ const SUPPORTED_FORM_FIELD_TYPES = [
|
||||
FieldMetadataType.UUID,
|
||||
FieldMetadataType.ARRAY,
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
FieldMetadataType.RICH_TEXT,
|
||||
];
|
||||
|
||||
@@ -37,8 +38,10 @@ export const shouldDisplayFormField = ({
|
||||
}
|
||||
|
||||
const isIdField = fieldMetadataItem.name === 'id';
|
||||
|
||||
const isNotSupportedRelation =
|
||||
fieldMetadataItem.type === FieldMetadataType.RELATION &&
|
||||
(fieldMetadataItem.type === FieldMetadataType.RELATION ||
|
||||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION) &&
|
||||
fieldMetadataItem.settings?.['relationType'] !== 'MANY_TO_ONE';
|
||||
|
||||
switch (actionType) {
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
|
||||
onVariableSelect,
|
||||
shouldDisplayRecordObjects = false,
|
||||
shouldDisplayRecordFields = true,
|
||||
objectNameSingularsToSelect,
|
||||
}) => {
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
|
||||
const isDropdownOpen = useAtomComponentStateValue(
|
||||
@@ -82,6 +83,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
|
||||
disabled={disabled}
|
||||
shouldDisplayRecordObjects={shouldDisplayRecordObjects}
|
||||
shouldDisplayRecordFields={shouldDisplayRecordFields}
|
||||
objectNameSingularsToSelect={objectNameSingularsToSelect}
|
||||
/>
|
||||
</StyledSearchVariablesDropdownContainer>
|
||||
);
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onVariableSelect,
|
||||
shouldDisplayRecordFields,
|
||||
shouldDisplayRecordObjects,
|
||||
objectNameSingularsToSelect,
|
||||
}: {
|
||||
clickableComponent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
@@ -47,6 +48,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
shouldDisplayRecordFields: boolean;
|
||||
shouldDisplayRecordObjects: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
}) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
|
||||
@@ -119,6 +121,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onSelect={handleSubItemSelect}
|
||||
onBack={handleBack}
|
||||
shouldDisplayRecordObjects={shouldDisplayRecordObjects}
|
||||
objectNameSingularsToSelect={objectNameSingularsToSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -29,6 +29,7 @@ type WorkflowVariablesDropdownStepItemsProps = {
|
||||
onSelect: (value: string) => void;
|
||||
onBack: () => void;
|
||||
shouldDisplayRecordObjects: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
};
|
||||
|
||||
export const WorkflowVariablesDropdownStepItems = ({
|
||||
@@ -36,6 +37,7 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
onSelect,
|
||||
onBack,
|
||||
shouldDisplayRecordObjects,
|
||||
objectNameSingularsToSelect,
|
||||
}: WorkflowVariablesDropdownStepItemsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
@@ -97,8 +99,18 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
: true;
|
||||
|
||||
const objectLabel = displayedSubStepObjectMetadata?.labelSingular;
|
||||
|
||||
const isSubStepObjectSelectable =
|
||||
!isDefined(objectNameSingularsToSelect) ||
|
||||
(isDefined(displayedSubStepObjectMetadata) &&
|
||||
objectNameSingularsToSelect.includes(
|
||||
displayedSubStepObjectMetadata.nameSingular,
|
||||
));
|
||||
|
||||
const shouldDisplaySubStepObject =
|
||||
shouldDisplayRecordObjects && isObjectFoundThroughSearch;
|
||||
shouldDisplayRecordObjects &&
|
||||
isObjectFoundThroughSearch &&
|
||||
isSubStepObjectSelectable;
|
||||
|
||||
const displayedSubStepObjectIconProps = isDefined(
|
||||
displayedSubStepObjectMetadata,
|
||||
|
||||
@@ -15,9 +15,14 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
|
||||
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@CoreResolver()
|
||||
@UseFilters(SearchApiExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
@UseFilters(
|
||||
SearchApiExceptionFilter,
|
||||
PermissionsGraphqlApiExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
|
||||
export class SearchResolver {
|
||||
|
||||
+161
-2
@@ -2,11 +2,44 @@ import { isObject, isString } from '@sniptt/guards';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
|
||||
import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
|
||||
type MorphRelationTargetJoinColumn = {
|
||||
joinColumnName: string;
|
||||
targetObjectMetadataId: string;
|
||||
};
|
||||
|
||||
type ExtractedMorphValue = {
|
||||
targetObjectMetadataId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const extractMorphValue = (value: unknown): ExtractedMorphValue | null => {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
isString(record.targetObjectMetadataId) &&
|
||||
isString(record.id) &&
|
||||
isDefined(record.id)
|
||||
) {
|
||||
return {
|
||||
targetObjectMetadataId: record.targetObjectMetadataId,
|
||||
id: record.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractLegacyRelationId = (value: unknown): string | undefined => {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
@@ -21,7 +54,113 @@ const extractLegacyRelationId = (value: unknown): string | undefined => {
|
||||
return record.id;
|
||||
};
|
||||
|
||||
export const formatWorkflowRecordRelationFields = (
|
||||
const formatWorkflowRecordMorphRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): {
|
||||
formattedRecord: Record<string, unknown>;
|
||||
joinColumnNamesByMorphFieldName: Record<string, string[]>;
|
||||
} => {
|
||||
const { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
objectMetadataInfo;
|
||||
|
||||
const objectFields = getFlatFieldsFromFlatObjectMetadata(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const targetJoinColumnsByMorphFieldName = new Map<
|
||||
string,
|
||||
MorphRelationTargetJoinColumn[]
|
||||
>();
|
||||
|
||||
for (const field of objectFields) {
|
||||
if (
|
||||
!isFlatFieldMetadataOfType(field, FieldMetadataType.MORPH_RELATION) ||
|
||||
field.settings.relationType !== RelationType.MANY_TO_ONE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: field.relationTargetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const morphFieldName = getMorphNameFromMorphFieldMetadataName({
|
||||
morphRelationFlatFieldMetadata: field,
|
||||
nameSingular: targetObjectMetadata.nameSingular,
|
||||
namePlural: targetObjectMetadata.namePlural,
|
||||
});
|
||||
|
||||
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
|
||||
name: field.name,
|
||||
});
|
||||
|
||||
const existing =
|
||||
targetJoinColumnsByMorphFieldName.get(morphFieldName) ?? [];
|
||||
|
||||
targetJoinColumnsByMorphFieldName.set(morphFieldName, [
|
||||
...existing,
|
||||
{
|
||||
joinColumnName,
|
||||
targetObjectMetadataId: field.relationTargetObjectMetadataId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const formattedRecord: Record<string, unknown> = {};
|
||||
const joinColumnNamesByMorphFieldName: Record<string, string[]> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const targetJoinColumns = targetJoinColumnsByMorphFieldName.get(key);
|
||||
|
||||
if (!isDefined(targetJoinColumns)) {
|
||||
formattedRecord[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const registerJoinColumns = () => {
|
||||
joinColumnNamesByMorphFieldName[key] = targetJoinColumns.map(
|
||||
(targetJoinColumn) => targetJoinColumn.joinColumnName,
|
||||
);
|
||||
|
||||
for (const { joinColumnName } of targetJoinColumns) {
|
||||
formattedRecord[joinColumnName] = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (value === null) {
|
||||
registerJoinColumns();
|
||||
continue;
|
||||
}
|
||||
|
||||
const morphValue = extractMorphValue(value);
|
||||
|
||||
const matchingTargetJoinColumn = isDefined(morphValue)
|
||||
? targetJoinColumns.find(
|
||||
(targetJoinColumn) =>
|
||||
targetJoinColumn.targetObjectMetadataId ===
|
||||
morphValue.targetObjectMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(morphValue) || !isDefined(matchingTargetJoinColumn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registerJoinColumns();
|
||||
formattedRecord[matchingTargetJoinColumn.joinColumnName] = morphValue.id;
|
||||
}
|
||||
|
||||
return { formattedRecord, joinColumnNamesByMorphFieldName };
|
||||
};
|
||||
|
||||
const formatWorkflowRecordSimpleRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): Record<string, unknown> => {
|
||||
@@ -70,3 +209,23 @@ export const formatWorkflowRecordRelationFields = (
|
||||
|
||||
return formattedRecord;
|
||||
};
|
||||
|
||||
export const formatWorkflowRecordRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): {
|
||||
formattedRecord: Record<string, unknown>;
|
||||
joinColumnNamesByMorphFieldName: Record<string, string[]>;
|
||||
} => {
|
||||
const {
|
||||
formattedRecord: recordWithMorphRelations,
|
||||
joinColumnNamesByMorphFieldName,
|
||||
} = formatWorkflowRecordMorphRelationFields(record, objectMetadataInfo);
|
||||
|
||||
const formattedRecord = formatWorkflowRecordSimpleRelationFields(
|
||||
recordWithMorphRelations,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
return { formattedRecord, joinColumnNamesByMorphFieldName };
|
||||
};
|
||||
|
||||
+5
-4
@@ -59,10 +59,11 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
|
||||
context,
|
||||
) as WorkflowCreateRecordActionInput;
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
const { formattedRecord: formattedObjectRecord } =
|
||||
formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
const filteredObjectRecord = filterValidFieldsInRecord(
|
||||
formattedObjectRecord,
|
||||
|
||||
+9
-2
@@ -82,7 +82,10 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
const {
|
||||
formattedRecord: formattedObjectRecord,
|
||||
joinColumnNamesByMorphFieldName,
|
||||
} = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
@@ -93,7 +96,11 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
|
||||
objectMetadataInfo.flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const filteredFieldsToUpdate = workflowActionInput.fieldsToUpdate?.filter(
|
||||
const expandedFieldsToUpdate = workflowActionInput.fieldsToUpdate?.flatMap(
|
||||
(fieldName) => joinColumnNamesByMorphFieldName[fieldName] ?? [fieldName],
|
||||
);
|
||||
|
||||
const filteredFieldsToUpdate = expandedFieldsToUpdate?.filter(
|
||||
(fieldName) => fieldName in filteredObjectRecord,
|
||||
);
|
||||
|
||||
|
||||
+5
-4
@@ -77,10 +77,11 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
const { formattedRecord: formattedObjectRecord } =
|
||||
formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
const filteredObjectRecord = filterValidFieldsInRecord(
|
||||
formattedObjectRecord,
|
||||
|
||||
Reference in New Issue
Block a user