Add delete record action (#8908)

<img width="1272" alt="Capture d’écran 2024-12-05 à 18 04 19"
src="https://github.com/user-attachments/assets/dcbcc761-2f6d-4b6d-9e10-0f5b25d12c39">

- Adding new action
- Adding tests
- Moving into action folder
This commit is contained in:
Thomas Trompette
2024-12-06 13:02:17 +01:00
committed by GitHub
parent ab22fd560a
commit 5c565345ae
14 changed files with 483 additions and 100 deletions

View File

@ -1,4 +1,3 @@
import { lazy, Suspense } from 'react';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/components/WorkflowEditTriggerManualForm';
import {
@ -8,11 +7,14 @@ import {
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { isWorkflowRecordCreateAction } from '@/workflow/utils/isWorkflowRecordCreateAction';
import { isWorkflowRecordUpdateAction } from '@/workflow/utils/isWorkflowRecordUpdateAction';
import { WorkflowEditActionFormRecordCreate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordCreate';
import { WorkflowEditActionFormRecordDelete } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordDelete';
import { WorkflowEditActionFormRecordUpdate } from '@/workflow/workflow-actions/components/WorkflowEditActionFormRecordUpdate';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { isWorkflowRecordCreateAction } from '@/workflow/workflow-actions/utils/isWorkflowRecordCreateAction';
import { isWorkflowRecordDeleteAction } from '@/workflow/workflow-actions/utils/isWorkflowRecordDeleteAction';
import { isWorkflowRecordUpdateAction } from '@/workflow/workflow-actions/utils/isWorkflowRecordUpdateAction';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-ui';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
@ -124,6 +126,15 @@ export const WorkflowStepDetail = ({
);
}
if (isWorkflowRecordDeleteAction(stepDefinition.definition)) {
return (
<WorkflowEditActionFormRecordDelete
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
return null;
}
}

View File

@ -30,4 +30,9 @@ export const ACTIONS: Array<{
type: 'RECORD_CRUD.UPDATE',
icon: IconAddressBook,
},
{
label: 'Delete Record',
type: 'RECORD_CRUD.DELETE',
icon: IconAddressBook,
},
];

View File

@ -1,79 +0,0 @@
import {
WorkflowCodeAction,
WorkflowRecordCRUDAction,
} from '@/workflow/types/Workflow';
import { isWorkflowRecordCreateAction } from '../isWorkflowRecordCreateAction';
it('returns false when providing an action that is not Record Create', () => {
const codeAction: WorkflowCodeAction = {
type: 'CODE',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(codeAction)).toBe(false);
});
it('returns false for Record Update', () => {
const codeAction: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'UPDATE',
objectName: '',
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(codeAction)).toBe(false);
});
it('returns true for Record Create', () => {
const codeAction: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'CREATE',
objectName: '',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(codeAction)).toBe(true);
});

View File

@ -0,0 +1,170 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowSingleRecordPicker } from '@/workflow/components/WorkflowSingleRecordPicker';
import { WorkflowRecordDeleteAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import {
HorizontalSeparator,
IconAddressBook,
isDefined,
useIcons,
} from 'twenty-ui';
import { JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormRecordDeleteProps = {
action: WorkflowRecordDeleteAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowRecordDeleteAction) => void;
};
};
type DeleteRecordFormData = {
objectName: string;
objectRecordId: string;
};
export const WorkflowEditActionFormRecordDelete = ({
action,
actionOptions,
}: WorkflowEditActionFormRecordDeleteProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const [formData, setFormData] = useState<DeleteRecordFormData>({
objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId,
});
const isFormDisabled = actionOptions.readonly;
const handleFieldChange = (
fieldName: keyof DeleteRecordFormData,
updatedValue: JsonValue,
) => {
const newFormData: DeleteRecordFormData = {
...formData,
[fieldName]: updatedValue,
};
setFormData(newFormData);
saveAction(newFormData);
};
useEffect(() => {
setFormData({
objectName: action.settings.input.objectName,
objectRecordId: action.settings.input.objectRecordId,
});
}, [action.settings.input]);
const selectedObjectMetadataItemNameSingular = formData.objectName;
const selectedObjectMetadataItem = activeObjectMetadataItems.find(
(item) => item.nameSingular === selectedObjectMetadataItemNameSingular,
);
if (!isDefined(selectedObjectMetadataItem)) {
throw new Error('Should have found the metadata item');
}
const saveAction = useDebouncedCallback(
async (formData: DeleteRecordFormData) => {
if (actionOptions.readonly === true) {
return;
}
const {
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId,
} = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
type: 'DELETE',
objectName: updatedObjectName,
objectRecordId: updatedObjectRecordId ?? '',
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Delete Record`;
return (
<WorkflowEditGenericFormBase
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={IconAddressBook}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: DeleteRecordFormData = {
objectName,
objectRecordId: '',
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
<HorizontalSeparator noMargin />
<WorkflowSingleRecordPicker
label="Record"
onChange={(objectRecordId) =>
handleFieldChange('objectRecordId', objectRecordId)
}
objectNameSingular={formData.objectName}
defaultValue={formData.objectRecordId}
/>
</WorkflowEditGenericFormBase>
);
};

View File

@ -1,11 +1,27 @@
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { WorkflowCodeAction } from '@/workflow/types/Workflow';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { getFunctionInputSchema } from '@/workflow/utils/getFunctionInputSchema';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-actions/utils/mergeDefaultFunctionInputAndFunctionInput';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Monaco } from '@monaco-editor/react';
import { editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { Fragment, ReactNode, useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
CodeEditor,
HorizontalSeparator,
@ -13,23 +29,7 @@ import {
isDefined,
} from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { usePreventOverlapCallback } from '~/hooks/usePreventOverlapCallback';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { editor } from 'monaco-editor';
import { Monaco } from '@monaco-editor/react';
import { WorkflowVariablePicker } from '@/workflow/components/WorkflowVariablePicker';
import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput';
import { getFunctionInputSchema } from '@/workflow/utils/getFunctionInputSchema';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useGetUpdatableWorkflowVersion } from '@/workflow/hooks/useGetUpdatableWorkflowVersion';
const StyledContainer = styled.div`
display: inline-flex;

View File

@ -0,0 +1,81 @@
import {
WorkflowCodeAction,
WorkflowRecordCRUDAction,
} from '@/workflow/types/Workflow';
import { isWorkflowRecordCreateAction } from '../isWorkflowRecordCreateAction';
describe('isWorkflowRecordCreateAction', () => {
it('returns false when providing an action that is not Record Create', () => {
const action: WorkflowCodeAction = {
type: 'CODE',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(action)).toBe(false);
});
it('returns false for Record Update', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'UPDATE',
objectName: '',
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(action)).toBe(false);
});
it('returns true for Record Create', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'CREATE',
objectName: '',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordCreateAction(action)).toBe(true);
});
});

View File

@ -0,0 +1,81 @@
import {
WorkflowCodeAction,
WorkflowRecordCRUDAction,
} from '@/workflow/types/Workflow';
import { isWorkflowRecordDeleteAction } from '../isWorkflowRecordDeleteAction';
describe('isWorkflowRecordDeleteAction', () => {
it('returns false when providing an action that is not Record Delete', () => {
const action: WorkflowCodeAction = {
type: 'CODE',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordDeleteAction(action)).toBe(false);
});
it('returns false for Record Update', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'UPDATE',
objectName: '',
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordDeleteAction(action)).toBe(false);
});
it('returns true for Record Delete', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'DELETE',
objectName: '',
objectRecordId: '',
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordDeleteAction(action)).toBe(true);
});
});

View File

@ -0,0 +1,81 @@
import {
WorkflowCodeAction,
WorkflowRecordCRUDAction,
} from '@/workflow/types/Workflow';
import { isWorkflowRecordUpdateAction } from '../isWorkflowRecordUpdateAction';
describe('isWorkflowRecordUpdateAction', () => {
it('returns false when providing an action that is not Record Create', () => {
const action: WorkflowCodeAction = {
type: 'CODE',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: '',
serverlessFunctionInput: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordUpdateAction(action)).toBe(false);
});
it('returns true for Record Update', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'UPDATE',
objectName: '',
objectRecord: {},
objectRecordId: '',
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordUpdateAction(action)).toBe(true);
});
it('returns false for Record Create', () => {
const action: WorkflowRecordCRUDAction = {
type: 'RECORD_CRUD',
id: '',
name: '',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
type: 'CREATE',
objectName: '',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
};
expect(isWorkflowRecordUpdateAction(action)).toBe(false);
});
});

View File

@ -1,4 +1,4 @@
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
import { mergeDefaultFunctionInputAndFunctionInput } from '../mergeDefaultFunctionInputAndFunctionInput';
describe('mergeDefaultFunctionInputAndFunctionInput', () => {
it('should merge properly', () => {

View File

@ -0,0 +1,12 @@
import {
WorkflowAction,
WorkflowRecordDeleteAction,
} from '@/workflow/types/Workflow';
export const isWorkflowRecordDeleteAction = (
action: WorkflowAction,
): action is WorkflowRecordDeleteAction => {
return (
action.type === 'RECORD_CRUD' && action.settings.input.type === 'DELETE'
);
};

View File

@ -169,6 +169,27 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case `${WorkflowActionType.RECORD_CRUD}.${WorkflowRecordCRUDType.DELETE}`: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
where: { workspaceId, isActive: true, isSystem: false },
});
return {
id: newStepId,
name: 'Delete Record',
type: WorkflowActionType.RECORD_CRUD,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
type: WorkflowRecordCRUDType.DELETE,
objectName: activeObjectMetadataItem?.nameSingular || '',
objectRecordId: '',
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,