Search record action - allow to search more than 1 record (#14769)

https://github.com/user-attachments/assets/426da6af-6c6b-4d49-9034-0aa041cb331f
This commit is contained in:
Thomas Trompette
2025-09-30 12:07:25 +02:00
committed by GitHub
parent 4b87908752
commit b7cdf9183a
9 changed files with 177 additions and 47 deletions
@@ -1,18 +1,47 @@
import { stepsOutputSchemaFamilyState } from '@/workflow/states/stepsOutputSchemaFamilyState';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import {
type WorkflowActionType,
type WorkflowVersion,
} from '@/workflow/types/Workflow';
import { getStepOutputSchemaFamilyStateKey } from '@/workflow/utils/getStepOutputSchemaFamilyStateKey';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema';
import {
type OutputSchemaV2,
type StepOutputSchemaV2,
} from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { FeatureFlagKey } from '~/generated/graphql';
const getFilteredOutputSchema = ({
stepType,
outputSchema,
isIteratorEnabled,
}: {
stepType: WorkflowActionType;
outputSchema: OutputSchemaV2;
isIteratorEnabled: boolean;
}) => {
if (!isIteratorEnabled && isFindRecordsOutputSchema(stepType, outputSchema)) {
const filteredOutputSchema = {
...outputSchema,
all: undefined,
};
return filteredOutputSchema;
}
return outputSchema;
};
export const useStepsOutputSchema = () => {
const isIteratorEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED,
);
const populateStepsOutputSchema = useRecoilCallback(
({ set }) =>
(workflowVersion: WorkflowVersion) => {
@@ -22,7 +51,11 @@ export const useStepsOutputSchema = () => {
name: step.name,
type: step.type,
icon: getActionIcon(step.type),
outputSchema: step.settings?.outputSchema as OutputSchemaV2,
outputSchema: getFilteredOutputSchema({
stepType: step.type,
outputSchema: step.settings?.outputSchema as OutputSchemaV2,
isIteratorEnabled,
}),
};
set(
@@ -59,7 +92,7 @@ export const useStepsOutputSchema = () => {
);
}
},
[],
[isIteratorEnabled],
);
const deleteStepsOutputSchema = useRecoilCallback(
@@ -19,6 +19,7 @@ import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-a
import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useLingui } from '@lingui/react/macro';
import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
@@ -230,8 +231,20 @@ export const WorkflowEditActionFindRecords = ({
label="Limit"
defaultValue={formData.limit}
placeholder="Enter limit"
onChange={() => {}}
readonly
onChange={(limit) => {
if (isFormDisabled === true || !isNumber(limit)) {
return;
}
const newFormData: FindRecordsFormData = {
...formData,
limit,
};
setFormData(newFormData);
saveAction(newFormData);
}}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
@@ -1,7 +1,8 @@
import type { Leaf } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordNode } from '@/workflow/workflow-variables/types/RecordNode';
export type FindRecordsOutputSchema = {
first: RecordNode;
last: RecordNode;
totalCount: number;
all: Leaf | undefined;
totalCount: Leaf;
};
@@ -36,12 +36,18 @@ describe('searchVariableThroughFindRecordsOutputSchema', () => {
label: 'First',
value: mockRecordSchema,
},
last: {
isLeaf: false,
label: 'Last',
value: mockRecordSchema,
all: {
isLeaf: true,
label: 'All',
value: 'Returns an array of records',
type: 'array',
},
totalCount: {
isLeaf: true,
label: 'Total Count',
value: 42,
type: 'number',
},
totalCount: 42,
};
it('should handle totalCount variable correctly', () => {
@@ -76,20 +82,18 @@ describe('searchVariableThroughFindRecordsOutputSchema', () => {
});
});
it('should handle last record field access correctly', () => {
it('should handle all records access correctly', () => {
const result = searchVariableThroughFindRecordsOutputSchema({
stepName: 'Find Companies',
searchRecordOutputSchema: mockSearchRecordSchema,
rawVariableName: '{{step1.last.revenue}}',
rawVariableName: '{{step1.all}}',
isFullRecord: false,
});
expect(result).toEqual({
variableLabel: 'Revenue',
variablePathLabel: 'Find Companies > Last > Revenue',
variableType: FieldMetadataType.NUMBER,
fieldMetadataId: 'company-revenue-metadata-id',
compositeFieldSubFieldName: undefined,
variableLabel: 'All',
variablePathLabel: 'Find Companies > All',
variableType: FieldMetadataType.ARRAY,
});
});
@@ -5,7 +5,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
type SearchResultKey = 'first' | 'last' | 'totalCount';
type SearchResultKey = 'first' | 'all' | 'totalCount';
/**
* Parses a variable name to extract its components for SearchRecord outputs
@@ -68,15 +68,7 @@ export const searchVariableThroughFindRecordsOutputSchema = ({
};
}
if (searchResultKey === 'totalCount') {
return {
variableLabel: 'Total Count',
variablePathLabel: `${stepName} > Total Count`,
variableType: FieldMetadataType.NUMBER,
};
}
if (searchResultKey === 'first' || searchResultKey === 'last') {
if (searchResultKey === 'first') {
const recordSchema = searchRecordOutputSchema[searchResultKey]?.value;
if (!isDefined(recordSchema) || !isDefined(fieldName)) {
@@ -87,7 +79,7 @@ export const searchVariableThroughFindRecordsOutputSchema = ({
}
return searchRecordOutputSchemaUtil({
stepName: `${stepName} > ${searchResultKey === 'first' ? 'First' : 'Last'}`,
stepName: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'First'}`,
recordOutputSchema: recordSchema,
selectedField: fieldName,
path: pathSegments,
@@ -95,6 +87,24 @@ export const searchVariableThroughFindRecordsOutputSchema = ({
});
}
if (searchResultKey === 'totalCount') {
return {
variableLabel:
searchRecordOutputSchema[searchResultKey]?.label ?? 'Total Count',
variablePathLabel: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'Total Count'}`,
variableType: FieldMetadataType.NUMBER,
};
}
if (searchResultKey === 'all') {
return {
variableLabel:
searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records',
variablePathLabel: `${stepName} > ${searchRecordOutputSchema[searchResultKey]?.label ?? 'All Records'}`,
variableType: FieldMetadataType.ARRAY,
};
}
return {
variableLabel: undefined,
variablePathLabel: undefined,
@@ -76,6 +76,14 @@ export const searchVariableThroughIteratorOutputSchema = ({
if (iteratorResultKey === 'currentItem') {
const schema = iteratorOutputSchema.currentItem.value;
if (!isDefined(schema)) {
return {
variableLabel: undefined,
variablePathLabel: undefined,
};
}
if (isRecordOutputSchemaV2(schema) && isDefined(fieldName)) {
return searchRecordOutputSchema({
stepName: `${stepName} > Current Item`,
@@ -28,6 +28,7 @@ import { AddPositionsToWorkflowVersionsAndWorkflowRunsCommand } from 'src/databa
import { MigrateViewsToCoreCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-migrate-views-to-core.command';
import { RemoveFavoriteViewRelationCommand } from 'src/database/commands/upgrade-version-command/1-5/1-5-remove-favorite-view-relation.command';
import { FixLabelIdentifierPositionAndVisibilityCommand } from 'src/database/commands/upgrade-version-command/1-6/1-6-fix-label-identifier-position-and-visibility.command';
import { BackfillWorkflowManualTriggerAvailabilityCommand } from 'src/database/commands/upgrade-version-command/1-7/1-7-backfill-workflow-manual-trigger-availability.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -79,6 +80,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
// 1.6 Commands
protected readonly fixLabelIdentifierPositionAndVisibilityCommand: FixLabelIdentifierPositionAndVisibilityCommand,
// 1.7 Commands
protected readonly backfillWorkflowManualTriggerAvailabilityCommand: BackfillWorkflowManualTriggerAvailabilityCommand,
) {
super(
workspaceRepository,
@@ -165,6 +169,13 @@ export class UpgradeCommand extends UpgradeCommandRunner {
afterSyncMetadata: [],
};
const commands_170: VersionCommands = {
beforeSyncMetadata: [
this.backfillWorkflowManualTriggerAvailabilityCommand,
],
afterSyncMetadata: [],
};
this.allCommands = {
'0.53.0': commands_053,
'0.54.0': commands_054,
@@ -177,6 +188,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
'1.4.0': commands_140,
'1.5.0': commands_150,
'1.6.0': commands_160,
'1.7.0': commands_170,
};
}
@@ -215,20 +215,37 @@ export class WorkflowSchemaWorkspaceService {
maxDepth: 0,
});
return {
first: {
isLeaf: false,
icon: 'IconAlpha',
value: recordOutputSchema,
},
last: { isLeaf: false, icon: 'IconOmega', value: recordOutputSchema },
totalCount: {
isLeaf: true,
icon: 'IconSum',
type: 'number',
value: generateFakeValue('number'),
},
const objectMetadataInfo =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
objectType,
workspaceId,
);
const first: Node = {
isLeaf: false,
label: `First ${objectMetadataInfo.objectMetadataItemWithFieldsMaps.labelSingular ?? 'Record'}`,
icon: 'IconAlpha',
type: 'object',
value: recordOutputSchema,
};
const all: Leaf = {
isLeaf: true,
label: `All ${objectMetadataInfo.objectMetadataItemWithFieldsMaps.labelPlural ?? 'Records'}`,
type: 'array',
icon: 'IconListDetails',
value: 'Returns an array of records',
};
const totalCount: Leaf = {
isLeaf: true,
label: 'Total Count',
icon: 'IconSum',
type: 'number',
value: 'Count of matching records',
};
return { first, all, totalCount } satisfies OutputSchema;
}
private async computeRecordOutputSchema({
@@ -403,8 +420,36 @@ export class WorkflowSchemaWorkspaceService {
}
}
// TODO(t.trompette): handle other step types
const step = workflowVersion.steps?.find((step) => step.id === stepId);
return DEFAULT_ITERATOR_CURRENT_ITEM;
if (!isDefined(step)) {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
switch (step.type) {
case WorkflowActionType.FIND_RECORDS: {
const objectMetadataInfo =
await this.workflowCommonWorkspaceService.getObjectMetadataItemWithFieldsMaps(
step.settings.input.objectName,
workspaceId,
);
return {
label:
'Current Item (' +
objectMetadataInfo.objectMetadataItemWithFieldsMaps.labelSingular +
')',
isLeaf: false,
type: 'object',
value: await this.computeRecordOutputSchema({
objectType: step.settings.input.objectName,
workspaceId,
}),
};
}
default: {
return DEFAULT_ITERATOR_CURRENT_ITEM;
}
}
}
}
@@ -107,7 +107,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
return {
result: {
first: objectRecords[0],
last: objectRecords[objectRecords.length - 1],
all: objectRecords,
totalCount,
},
};
@@ -144,7 +144,11 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
);
return withOrderByQueryBuilder
.take(workflowActionInput.limit ?? QUERY_MAX_RECORDS)
.take(
workflowActionInput.limit
? Math.min(workflowActionInput.limit, QUERY_MAX_RECORDS)
: QUERY_MAX_RECORDS,
)
.getMany();
}