Create base search record action (#10135)

Create Search Record action without filters neither sorting

<img width="234" alt="Capture d’écran 2025-02-11 à 18 19 25"
src="https://github.com/user-attachments/assets/f18caaa3-476a-436f-9f93-1ad506b24da2"
/>
<img width="262" alt="Capture d’écran 2025-02-11 à 18 19 38"
src="https://github.com/user-attachments/assets/25fcbfcf-57fb-476f-aba9-119be7c3e067"
/>
<img width="236" alt="Capture d’écran 2025-02-11 à 18 19 53"
src="https://github.com/user-attachments/assets/1eb2be25-0727-4797-868c-afdc05040e6a"
/>
This commit is contained in:
Thomas Trompette
2025-02-12 11:29:32 +01:00
committed by GitHub
parent 9cc6ea501a
commit 33af71ccd3
7 changed files with 292 additions and 3 deletions

View File

@ -55,6 +55,13 @@ export type WorkflowDeleteRecordActionSettings = BaseWorkflowActionSettings & {
};
};
export type WorkflowFindRecordsActionSettings = BaseWorkflowActionSettings & {
input: {
objectName: string;
limit?: number;
};
};
type BaseWorkflowAction = {
id: string;
name: string;
@ -86,12 +93,18 @@ export type WorkflowDeleteRecordAction = BaseWorkflowAction & {
settings: WorkflowDeleteRecordActionSettings;
};
export type WorkflowFindRecordsAction = BaseWorkflowAction & {
type: 'FIND_RECORDS';
settings: WorkflowFindRecordsActionSettings;
};
export type WorkflowAction =
| WorkflowCodeAction
| WorkflowSendEmailAction
| WorkflowCreateRecordAction
| WorkflowUpdateRecordAction
| WorkflowDeleteRecordAction;
| WorkflowDeleteRecordAction
| WorkflowFindRecordsAction;
export type WorkflowActionType = WorkflowAction['type'];

View File

@ -7,11 +7,12 @@ import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
import { WorkflowEditActionFormDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormDeleteRecord';
import { WorkflowEditActionFormFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormFindRecords';
import { WorkflowEditActionFormSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormUpdateRecord';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { WorkflowEditTriggerDatabaseEventForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerDatabaseEventForm';
import { WorkflowEditTriggerManualForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualForm';
import { WorkflowEditTriggerCronForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm';
import { Suspense, lazy } from 'react';
import { isDefined } from 'twenty-shared';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
@ -138,6 +139,16 @@ export const WorkflowStepDetail = ({
/>
);
}
case 'FIND_RECORDS': {
return (
<WorkflowEditActionFormFindRecords
key={stepId}
action={stepDefinition.definition}
actionOptions={props}
/>
);
}
}
return null;

View File

@ -0,0 +1,147 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useTheme } from '@emotion/react';
import { useEffect, useState } from 'react';
import { FormNumberFieldInput } from '@/object-record/record-field/form-types/components/FormNumberFieldInput';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { isDefined } from 'twenty-shared';
import { HorizontalSeparator, useIcons } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
type WorkflowEditActionFormFindRecordsProps = {
action: WorkflowFindRecordsAction;
actionOptions:
| {
readonly: true;
}
| {
readonly?: false;
onActionUpdate: (action: WorkflowFindRecordsAction) => void;
};
};
type FindRecordsFormData = {
objectName: string;
limit?: number;
};
export const WorkflowEditActionFormFindRecords = ({
action,
actionOptions,
}: WorkflowEditActionFormFindRecordsProps) => {
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<FindRecordsFormData>({
objectName: action.settings.input.objectName,
limit: action.settings.input.limit,
});
const isFormDisabled = actionOptions.readonly;
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: FindRecordsFormData) => {
if (actionOptions.readonly === true) {
return;
}
const { objectName: updatedObjectName, limit: updatedLimit } = formData;
actionOptions.onActionUpdate({
...action,
settings: {
...action.settings,
input: {
objectName: updatedObjectName,
limit: updatedLimit ?? 1,
},
},
});
},
1_000,
);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const headerTitle = isDefined(action.name) ? action.name : `Search Records`;
const headerIcon = getActionIcon(action.type);
return (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
headerType="Action"
disabled={isFormDisabled}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-find-records-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={formData.objectName}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectName) => {
const newFormData: FindRecordsFormData = {
objectName,
limit: 1,
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
/>
<HorizontalSeparator noMargin />
<FormNumberFieldInput
label="Limit"
defaultValue={formData.limit}
placeholder="Enter limit"
onPersist={() => {}}
readonly
/>
</WorkflowStepBody>
</>
);
};

View File

@ -0,0 +1,92 @@
import { WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormFindRecords } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormFindRecords';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
const DEFAULT_ACTION = {
id: getWorkflowNodeIdMock(),
name: 'Search Records',
type: 'FIND_RECORDS',
valid: false,
settings: {
input: {
objectName: 'person',
limit: 1,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
} satisfies WorkflowFindRecordsAction;
const meta: Meta<typeof WorkflowEditActionFormFindRecords> = {
title: 'Modules/Workflow/WorkflowEditActionFormFindRecords',
component: WorkflowEditActionFormFindRecords,
parameters: {
msw: graphqlMocks,
},
args: {
action: DEFAULT_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionFormFindRecords>;
export const Default: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const DisabledWithEmptyValues: Story = {
args: {
actionOptions: {
readonly: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleInput = await canvas.findByDisplayValue('Search Records');
expect(titleInput).toBeDisabled();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
{
const searchInputInSelectDropdown =
canvas.queryByPlaceholderText('Search');
expect(searchInputInSelectDropdown).not.toBeInTheDocument();
}
},
};

View File

@ -20,4 +20,9 @@ export const RECORD_ACTIONS: Array<{
type: 'DELETE_RECORD',
icon: 'IconTrash',
},
{
label: 'Search Records',
type: 'FIND_RECORDS',
icon: 'IconSearch',
},
];

View File

@ -6,6 +6,7 @@ export const getActionIcon = (actionType: string) => {
case 'CREATE_RECORD':
case 'UPDATE_RECORD':
case 'DELETE_RECORD':
case 'FIND_RECORDS':
return RECORD_ACTIONS.find((item) => item.type === actionType)?.icon;
default:
return OTHER_ACTIONS.find((item) => item.type === actionType)?.icon;

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
@ -186,6 +186,26 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case WorkflowActionType.FIND_RECORDS: {
const activeObjectMetadataItem =
await this.objectMetadataRepository.findOne({
where: { workspaceId, isActive: true, isSystem: false },
});
return {
id: newStepId,
name: 'Search Records',
type: WorkflowActionType.FIND_RECORDS,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
objectName: activeObjectMetadataItem?.nameSingular || '',
limit: 1,
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,