Add fields to update in update record action (#9108)

- update backend action so it handles composite fields
- add fields to update multiselect
- generate form based on that field
- add icons
This commit is contained in:
Thomas Trompette 2024-12-18 14:32:21 +01:00 committed by GitHub
parent b6508cc615
commit 94676215ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 191 additions and 15 deletions

View File

@ -11,6 +11,7 @@ import { SelectOption } from '@/spreadsheet-import/types';
import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay'; import { MultiSelectDisplay } from '@/ui/field/display/components/MultiSelectDisplay';
import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput'; import { MultiSelectInput } from '@/ui/field/input/components/MultiSelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel'; import { InputLabel } from '@/ui/input/components/InputLabel';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString'; import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useId, useState } from 'react'; import { useId, useState } from 'react';
@ -20,9 +21,9 @@ import { isDefined } from '~/utils/isDefined';
type FormMultiSelectFieldInputProps = { type FormMultiSelectFieldInputProps = {
label?: string; label?: string;
defaultValue: FieldMultiSelectValue | string | undefined; defaultValue: FieldMultiSelectValue | string | undefined;
options: SelectOption[];
onPersist: (value: FieldMultiSelectValue | string) => void; onPersist: (value: FieldMultiSelectValue | string) => void;
VariablePicker?: VariablePickerComponent; VariablePicker?: VariablePickerComponent;
options: SelectOption[];
}; };
const StyledDisplayModeContainer = styled.button` const StyledDisplayModeContainer = styled.button`
@ -50,9 +51,9 @@ const StyledSelectInputContainer = styled.div`
export const FormMultiSelectFieldInput = ({ export const FormMultiSelectFieldInput = ({
label, label,
defaultValue, defaultValue,
options,
onPersist, onPersist,
VariablePicker, VariablePicker,
options,
}: FormMultiSelectFieldInputProps) => { }: FormMultiSelectFieldInputProps) => {
const inputId = useId(); const inputId = useId();
@ -189,6 +190,7 @@ export const FormMultiSelectFieldInput = ({
<StyledSelectInputContainer> <StyledSelectInputContainer>
{draftValue.type === 'static' && {draftValue.type === 'static' &&
draftValue.editingMode === 'edit' && ( draftValue.editingMode === 'edit' && (
<OverlayContainer>
<MultiSelectInput <MultiSelectInput
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
options={options} options={options}
@ -196,6 +198,7 @@ export const FormMultiSelectFieldInput = ({
onOptionSelected={onOptionSelected} onOptionSelected={onOptionSelected}
values={draftValue.value} values={draftValue.value}
/> />
</OverlayContainer>
)} )}
</StyledSelectInputContainer> </StyledSelectInputContainer>

View File

@ -40,6 +40,7 @@ export const MultiSelectDisplay = ({
key={index} key={index}
color={selectedOption.color ?? 'transparent'} color={selectedOption.color ?? 'transparent'}
text={selectedOption.label} text={selectedOption.label}
Icon={selectedOption.icon ?? undefined}
/> />
))} ))}
</StyledContainer> </StyledContainer>

View File

@ -127,6 +127,7 @@ export const MultiSelectInput = ({
selected={values?.includes(option.value) || false} selected={values?.includes(option.value) || false}
text={option.label} text={option.label}
color={option.color ?? 'transparent'} color={option.color ?? 'transparent'}
Icon={option.icon ?? undefined}
onClick={() => onClick={() =>
onOptionSelected(formatNewSelectedOptions(option.value)) onOptionSelected(formatNewSelectedOptions(option.value))
} }

View File

@ -44,6 +44,7 @@ export type WorkflowUpdateRecordActionSettings = BaseWorkflowActionSettings & {
objectName: string; objectName: string;
objectRecord: ObjectRecord; objectRecord: ObjectRecord;
objectRecordId: string; objectRecordId: string;
fieldsToUpdate: string[];
}; };
}; };

View File

@ -12,9 +12,14 @@ import {
useIcons, useIcons,
} from 'twenty-ui'; } from 'twenty-ui';
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 { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody'; import { WorkflowStepBody } from '@/workflow/components/WorkflowStepBody';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { JsonValue } from 'type-fest'; import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type WorkflowEditActionFormUpdateRecordProps = { type WorkflowEditActionFormUpdateRecordProps = {
action: WorkflowUpdateRecordAction; action: WorkflowUpdateRecordAction;
@ -31,9 +36,23 @@ type WorkflowEditActionFormUpdateRecordProps = {
type UpdateRecordFormData = { type UpdateRecordFormData = {
objectName: string; objectName: string;
objectRecordId: string; objectRecordId: string;
fieldsToUpdate: string[];
[field: string]: unknown; [field: string]: unknown;
}; };
const AVAILABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.Text,
FieldMetadataType.Number,
FieldMetadataType.Date,
FieldMetadataType.Boolean,
FieldMetadataType.Select,
FieldMetadataType.MultiSelect,
FieldMetadataType.Emails,
FieldMetadataType.Links,
FieldMetadataType.FullName,
FieldMetadataType.Address,
];
export const WorkflowEditActionFormUpdateRecord = ({ export const WorkflowEditActionFormUpdateRecord = ({
action, action,
actionOptions, actionOptions,
@ -53,6 +72,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
const [formData, setFormData] = useState<UpdateRecordFormData>({ const [formData, setFormData] = useState<UpdateRecordFormData>({
objectName: action.settings.input.objectName, objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId, objectRecordId: action.settings.input.objectRecordId,
fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [],
...action.settings.input.objectRecord, ...action.settings.input.objectRecord,
}); });
const isFormDisabled = actionOptions.readonly; const isFormDisabled = actionOptions.readonly;
@ -75,6 +95,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
setFormData({ setFormData({
objectName: action.settings.input.objectName, objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId, objectRecordId: action.settings.input.objectRecordId,
fieldsToUpdate: action.settings.input.fieldsToUpdate ?? [],
...action.settings.input.objectRecord, ...action.settings.input.objectRecord,
}); });
}, [action.settings.input]); }, [action.settings.input]);
@ -88,6 +109,27 @@ export const WorkflowEditActionFormUpdateRecord = ({
throw new Error('Should have found the metadata item'); throw new Error('Should have found the metadata item');
} }
const inlineFieldMetadataItems = selectedObjectMetadataItem.fields
.filter(
(fieldMetadataItem) =>
!fieldMetadataItem.isSystem &&
fieldMetadataItem.isActive &&
AVAILABLE_FIELD_METADATA_TYPES.includes(fieldMetadataItem.type),
)
.sort((fieldMetadataItemA, fieldMetadataItemB) =>
fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name),
);
const inlineFieldDefinitions = inlineFieldMetadataItems.map(
(fieldMetadataItem) =>
formatFieldMetadataItemAsFieldDefinition({
field: fieldMetadataItem,
objectMetadataItem: selectedObjectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
);
const saveAction = useDebouncedCallback( const saveAction = useDebouncedCallback(
async (formData: UpdateRecordFormData) => { async (formData: UpdateRecordFormData) => {
if (actionOptions.readonly === true) { if (actionOptions.readonly === true) {
@ -97,6 +139,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
const { const {
objectName: updatedObjectName, objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId, objectRecordId: updatedObjectRecordId,
fieldsToUpdate: updatedFieldsToUpdate,
...updatedOtherFields ...updatedOtherFields
} = formData; } = formData;
@ -108,6 +151,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
objectName: updatedObjectName, objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '', objectRecordId: updatedObjectRecordId ?? '',
objectRecord: updatedOtherFields, objectRecord: updatedOtherFields,
fieldsToUpdate: updatedFieldsToUpdate ?? [],
}, },
}, },
}); });
@ -154,6 +198,7 @@ export const WorkflowEditActionFormUpdateRecord = ({
const newFormData: UpdateRecordFormData = { const newFormData: UpdateRecordFormData = {
objectName: updatedObjectName, objectName: updatedObjectName,
objectRecordId: '', objectRecordId: '',
fieldsToUpdate: [],
}; };
setFormData(newFormData); setFormData(newFormData);
@ -172,6 +217,48 @@ export const WorkflowEditActionFormUpdateRecord = ({
objectNameSingular={formData.objectName} objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId} defaultValue={formData.objectRecordId}
/> />
<FormMultiSelectFieldInput
label="Fields to update"
defaultValue={formData.fieldsToUpdate}
options={inlineFieldDefinitions.map((field) => ({
label: field.label,
value: field.metadata.fieldName,
icon: getIcon(field.iconName),
color: 'gray',
}))}
onPersist={(fieldsToUpdate) =>
handleFieldChange('fieldsToUpdate', fieldsToUpdate)
}
/>
<HorizontalSeparator noMargin />
{formData.fieldsToUpdate.map((fieldName) => {
const fieldDefinition = inlineFieldDefinitions.find(
(definition) => definition.metadata.fieldName === fieldName,
);
if (!isDefined(fieldDefinition)) {
return null;
}
const currentValue = formData[
fieldDefinition.metadata.fieldName
] as JsonValue;
return (
<FormFieldInput
key={fieldDefinition.metadata.fieldName}
defaultValue={currentValue}
field={fieldDefinition}
onPersist={(value) => {
handleFieldChange(fieldDefinition.metadata.fieldName, value);
}}
VariablePicker={WorkflowVariablePicker}
/>
);
})}
</WorkflowStepBody> </WorkflowStepBody>
</> </>
); );

View File

@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto'; import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@ -20,7 +21,6 @@ import {
WorkflowActionType, WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined'; import { isDefined } from 'src/utils/is-defined';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
const TRIGGER_STEP_ID = 'trigger'; const TRIGGER_STEP_ID = 'trigger';
@ -152,6 +152,7 @@ export class WorkflowVersionStepWorkspaceService {
objectName: activeObjectMetadataItem?.nameSingular || '', objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecord: {}, objectRecord: {},
objectRecordId: '', objectRecordId: '',
fieldsToUpdate: [],
}, },
}, },
}; };

View File

@ -14,6 +14,7 @@ export type WorkflowUpdateRecordActionInput = {
objectName: string; objectName: string;
objectRecord: ObjectRecord; objectRecord: ObjectRecord;
objectRecordId: string; objectRecordId: string;
fieldsToUpdate: string[];
}; };
export type WorkflowDeleteRecordActionInput = { export type WorkflowDeleteRecordActionInput = {

View File

@ -2,7 +2,11 @@ import { Injectable } from '@nestjs/common';
import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; import { WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { import {
RecordCRUDActionException, RecordCRUDActionException,
RecordCRUDActionExceptionCode, RecordCRUDActionExceptionCode,
@ -12,7 +16,11 @@ import { WorkflowActionResult } from 'src/modules/workflow/workflow-executor/wor
@Injectable() @Injectable()
export class UpdateRecordWorkflowAction implements WorkflowAction { export class UpdateRecordWorkflowAction implements WorkflowAction {
constructor(private readonly twentyORMManager: TwentyORMManager) {} constructor(
private readonly twentyORMManager: TwentyORMManager,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {}
async execute( async execute(
workflowActionInput: WorkflowUpdateRecordActionInput, workflowActionInput: WorkflowUpdateRecordActionInput,
@ -34,14 +42,85 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
); );
} }
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) {
throw new RecordCRUDActionException(
'Failed to read: Workspace ID is required',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const currentCacheVersion =
await this.workspaceCacheStorageService.getMetadataVersion(workspaceId);
if (currentCacheVersion === undefined) {
throw new RecordCRUDActionException(
'Failed to read: Metadata cache version not found',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const objectMetadataMaps =
await this.workspaceCacheStorageService.getObjectMetadataMaps(
workspaceId,
currentCacheVersion,
);
if (!objectMetadataMaps) {
throw new RecordCRUDActionException(
'Failed to read: Object metadata collection not found',
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadataMaps,
workflowActionInput.objectName,
);
if (!objectMetadataItemWithFieldsMaps) {
throw new RecordCRUDActionException(
`Failed to read: Object ${workflowActionInput.objectName} not found`,
RecordCRUDActionExceptionCode.INVALID_REQUEST,
);
}
if (workflowActionInput.fieldsToUpdate.length === 0) {
return {
result: {
...objectRecord,
},
};
}
const objectRecordWithFilteredFields = Object.keys(
workflowActionInput.objectRecord,
).reduce((acc, key) => {
if (workflowActionInput.fieldsToUpdate.includes(key)) {
return {
...acc,
[key]: workflowActionInput.objectRecord[key],
};
}
return acc;
}, {});
const objectRecordFormatted = formatData(
objectRecordWithFilteredFields,
objectMetadataItemWithFieldsMaps,
);
await repository.update(workflowActionInput.objectRecordId, { await repository.update(workflowActionInput.objectRecordId, {
...workflowActionInput.objectRecord, ...objectRecordFormatted,
}); });
return { return {
result: { result: {
...objectRecord, ...objectRecord,
...workflowActionInput.objectRecord, ...objectRecordWithFilteredFields,
}, },
}; };
} }

View File

@ -1,4 +1,4 @@
import { Tag } from '@ui/display'; import { IconComponent, Tag } from '@ui/display';
import { Checkbox, CheckboxShape, CheckboxSize } from '@ui/input'; import { Checkbox, CheckboxShape, CheckboxSize } from '@ui/input';
import { ThemeColor } from '@ui/theme'; import { ThemeColor } from '@ui/theme';
import { import {
@ -13,6 +13,7 @@ type MenuItemMultiSelectTagProps = {
onClick?: () => void; onClick?: () => void;
color: ThemeColor | 'transparent'; color: ThemeColor | 'transparent';
text: string; text: string;
Icon?: IconComponent;
}; };
export const MenuItemMultiSelectTag = ({ export const MenuItemMultiSelectTag = ({
@ -22,6 +23,7 @@ export const MenuItemMultiSelectTag = ({
onClick, onClick,
isKeySelected, isKeySelected,
text, text,
Icon,
}: MenuItemMultiSelectTagProps) => { }: MenuItemMultiSelectTagProps) => {
return ( return (
<StyledMenuItemBase <StyledMenuItemBase
@ -35,7 +37,7 @@ export const MenuItemMultiSelectTag = ({
checked={selected} checked={selected}
/> />
<StyledMenuItemLeftContent> <StyledMenuItemLeftContent>
<Tag color={color} text={text} /> <Tag color={color} text={text} Icon={Icon} />
</StyledMenuItemLeftContent> </StyledMenuItemLeftContent>
</StyledMenuItemBase> </StyledMenuItemBase>
); );