From 2c34e1fb8ada52a344d0cc32b548eef7f35d2cd4 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Fri, 26 Sep 2025 14:17:40 +0200 Subject: [PATCH] Allow bulk records for manual trigger (#14725) https://github.com/user-attachments/assets/d6c565eb-9a29-4830-9396-5f979c8caa7b - Added a new component for manual trigger (mostly duplicated from previous one). Will remove the old one once all data are migrated - Updated schema output so the current item of the iterator can be typed Todo left: - migrate old triggers - add an util to search iterator output. Today current item fields will be displayed as not found - set new manual triggers for workflow runs --- .../src/generated-metadata/graphql.ts | 2 + .../twenty-front/src/generated/graphql.ts | 2 + ...ndMenuWorkflowSelectTriggerTypeContent.tsx | 6 + .../ui/form-types/components/VariableChip.tsx | 2 +- .../src/modules/workflow/types/Workflow.ts | 8 + .../components/WorkflowRunStepNodeDetail.tsx | 23 +- .../components/WorkflowStepDetail.tsx | 20 +- .../utils/getWorkflowVariablesUsedInStep.ts | 2 +- .../WorkflowDropdownStepOutputItems.tsx | 2 +- .../WorkflowStepFilterFieldSelect.tsx | 2 +- .../components/WorkflowEditTriggerManual.tsx | 241 ++++++++++++++++++ ...> WorkflowEditTriggerManualDeprecated.tsx} | 10 +- .../ManualTriggerAvailabilityTypeOptions.ts | 28 ++ .../useUpdateWorkflowVersionTrigger.test.ts | 5 +- .../hooks/useUpdateWorkflowVersionTrigger.ts | 1 + ...lTriggerDefaultSettingsDeprecated.test.ts} | 10 +- .../getTriggerDefaultDefinition.test.ts | 9 + .../utils/getManualTriggerDefaultSettings.ts | 43 +++- ...tManualTriggerDefaultSettingsDeprecated.ts | 40 +++ .../utils/getTriggerDefaultDefinition.ts | 15 +- .../types/ManualTriggerOutputSchema.ts | 6 + .../types/StepOutputSchemaV2.ts | 4 +- .../guards/isManualTriggerOutputSchema.ts | 13 + .../types/guards/isRecordStepOutputSchema.ts | 7 +- .../__tests__/extractVariableLabel.test.ts | 2 +- .../utils/removeVariablesFromJson.ts | 2 +- .../searchVariableThroughBaseOutputSchema.ts | 2 +- ...hVariableThroughFindRecordsOutputSchema.ts | 2 +- .../searchVariableThroughFormOutputSchema.ts | 2 +- ...ariableThroughManualTriggerOutputSchema.ts | 32 +++ .../searchVariableThroughOutputSchemaV2.ts | 11 + ...hVariableThroughRecordEventOutputSchema.ts | 2 +- ...searchVariableThroughRecordOutputSchema.ts | 2 +- .../workflow-variables/utils/variableTag.ts | 2 +- .../compute-step-output-schema-input.dto.ts | 7 + .../resolvers/workflow-builder.resolver.ts | 3 +- .../src/engine/utils/generate-fake-value.ts | 2 +- ...workflow-version-step-changes.util.spec.ts | 1 - .../default-iterator-current-item.const.ts | 8 + .../generate-fake-array-item.spec.ts | 38 +++ .../utils/generate-fake-array-item.spec.ts | 39 +++ .../utils/generate-fake-array-item.ts | 34 +++ .../workflow-schema.workspace-service.ts | 193 ++++++++++++-- ...workflow-version-step.workspace-service.ts | 6 + .../schemas/workflow-tool-schemas.ts | 1 + .../workflow-tool.workspace-service.ts | 2 + .../types/workflow-trigger.type.ts | 11 +- .../CaptureAllVariableTagInnerRegex.ts | 0 packages/twenty-shared/src/workflow/index.ts | 7 + .../workflow/schemas/manual-trigger-schema.ts | 21 +- .../utils/extractRawVariableNameParts.ts} | 8 +- .../types/workflow-trigger.type.ts | 14 + 52 files changed, 882 insertions(+), 73 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManual.tsx rename packages/twenty-front/src/modules/workflow/workflow-trigger/components/{WorkflowEditTriggerManualForm.tsx => WorkflowEditTriggerManualDeprecated.tsx} (95%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/constants/ManualTriggerAvailabilityTypeOptions.ts rename packages/twenty-front/src/modules/workflow/workflow-trigger/utils/__tests__/{getManualTriggerDefaultSettings.test.ts => getManualTriggerDefaultSettingsDeprecated.test.ts} (85%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-trigger/utils/getManualTriggerDefaultSettingsDeprecated.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/ManualTriggerOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/__tests__/generate-fake-array-item.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-array-item.spec.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/utils/generate-fake-array-item.ts rename packages/{twenty-front/src/modules/workflow/workflow-variables => twenty-shared/src/workflow}/constants/CaptureAllVariableTagInnerRegex.ts (100%) rename packages/{twenty-front/src/modules/workflow/workflow-variables/utils/extractRawVariableNamePart.ts => twenty-shared/src/workflow/utils/extractRawVariableNameParts.ts} (73%) create mode 100644 packages/twenty-shared/src/workflow/workflow-trigger/types/workflow-trigger.type.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f5e0420646a..9d59b317a67 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -444,6 +444,8 @@ export type ClientAiModelConfig = { export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; + /** Workflow version ID */ + workflowVersionId: Scalars['UUID']; }; export enum ConfigSource { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index a23ae1ff0e6..6265c1a1003 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -444,6 +444,8 @@ export type ClientAiModelConfig = { export type ComputeStepOutputSchemaInput = { /** Step JSON format */ step: Scalars['JSON']; + /** Workflow version ID */ + workflowVersionId: Scalars['UUID']; }; export enum ConfigSource { diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx index b5eda6aef9c..84fa6d96c5d 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerTypeContent.tsx @@ -12,10 +12,12 @@ import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/Da import { OTHER_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/OtherTriggerTypes'; import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger'; import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useTheme } from '@emotion/react'; import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; import { useIcons } from 'twenty-ui/display'; import { MenuItem } from 'twenty-ui/navigation'; +import { FeatureFlagKey } from '~/generated/graphql'; export const CommandMenuWorkflowSelectTriggerTypeContent = ({ workflow, @@ -32,6 +34,9 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({ workflowSelectedNodeComponentState, ); const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu(); + const isIteratorEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED, + ); const handleTriggerTypeClick = ({ type, @@ -48,6 +53,7 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({ defaultLabel, type, activeNonSystemObjectMetadataItems, + isIteratorEnabled, }), ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx index b4ecb8a0ece..4a43e5ce582 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/VariableChip.tsx @@ -1,9 +1,9 @@ import { useSearchVariable } from '@/workflow/workflow-variables/hooks/useSearchVariable'; -import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart'; import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; +import { extractRawVariableNamePart } from 'twenty-shared/workflow'; import { IconAlertTriangle, IconX } from 'twenty-ui/display'; const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>` diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index bc94c37b1f4..088a58d1318 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -1,4 +1,7 @@ import { + type BulkRecordsAvailability, + type GlobalAvailability, + type SingleRecordAvailability, type workflowAiAgentActionSchema, type workflowCodeActionSchema, type workflowCreateRecordActionSchema, @@ -82,6 +85,11 @@ export type WorkflowManualTriggerAvailability = | 'EVERYWHERE' | 'WHEN_RECORD_SELECTED'; +export type WorkflowManualTriggerAvailabilityV2 = + | GlobalAvailability + | SingleRecordAvailability + | BulkRecordsAvailability; + export type WorkflowTrigger = z.infer; export type WorkflowTriggerType = WorkflowTrigger['type']; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index 7525eba490e..57d458c34b8 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -18,9 +18,12 @@ import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflo import { WorkflowEditActionIterator } from '@/workflow/workflow-steps/workflow-actions/iterator-action/WorkflowEditActionIterator'; 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 { WorkflowEditTriggerManual } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManual'; +import { WorkflowEditTriggerManualDeprecated } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerManualDeprecated'; import { WorkflowEditTriggerWebhookForm } from '@/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { FeatureFlagKey } from '~/generated/graphql'; type WorkflowRunStepNodeDetailProps = { stepId: string; @@ -41,6 +44,10 @@ export const WorkflowRunStepNodeDetail = ({ steps, }); + const isIteratorEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED, + ); + if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) { return null; } @@ -60,8 +67,20 @@ export const WorkflowRunStepNodeDetail = ({ ); } case 'MANUAL': { + if (isIteratorEnabled) { + return ( + + ); + } + return ( - { + const isIteratorEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_ITERATOR_ENABLED, + ); const stepDefinition = getStepDefinitionOrThrow({ stepId, trigger, @@ -68,8 +74,18 @@ export const WorkflowStepDetail = ({ ); } case 'MANUAL': { + if (isIteratorEnabled) { + return ( + + ); + } + return ( - { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowDropdownStepOutputItems.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowDropdownStepOutputItems.tsx index 581d38dd9cb..57ac4bccf00 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowDropdownStepOutputItems.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowDropdownStepOutputItems.tsx @@ -15,7 +15,6 @@ import { getStepFilterOperands } from '@/workflow/workflow-steps/workflow-action import { useVariableDropdown } from '@/workflow/workflow-variables/hooks/useVariableDropdown'; import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2'; import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; -import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart'; import { getCurrentSubStepFromPath } from '@/workflow/workflow-variables/utils/getCurrentSubStepFromPath'; import { getStepHeaderLabel } from '@/workflow/workflow-variables/utils/getStepHeaderLabel'; import { getStepItemIcon } from '@/workflow/workflow-variables/utils/getStepItemIcon'; @@ -25,6 +24,7 @@ import { useLingui } from '@lingui/react/macro'; import { useRecoilCallback } from 'recoil'; import { type StepFilter } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { extractRawVariableNamePart } from 'twenty-shared/workflow'; import { IconChevronLeft, OverflowingTextWithTooltip, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx index a4d5ec813cb..ad10e93ac56 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx @@ -9,12 +9,12 @@ import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variabl import { useSearchVariable } from '@/workflow/workflow-variables/hooks/useSearchVariable'; import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2'; -import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart'; import { useTheme } from '@emotion/react'; import { useLingui } from '@lingui/react/macro'; import { useContext, useState } from 'react'; import { type StepFilter } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { extractRawVariableNamePart } from 'twenty-shared/workflow'; import { useIcons } from 'twenty-ui/display'; import { FieldMetadataType } from '~/generated-metadata/graphql'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManual.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManual.tsx new file mode 100644 index 00000000000..800ef0aaa28 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerManual.tsx @@ -0,0 +1,241 @@ +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { IconPicker } from '@/ui/input/components/IconPicker'; +import { Select } from '@/ui/input/components/Select'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { type WorkflowManualTrigger } from '@/workflow/types/Workflow'; +import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; +import { MANUAL_TRIGGER_AVAILABILITY_TYPE_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerAvailabilityTypeOptions'; +import { MANUAL_TRIGGER_IS_PINNED_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerIsPinnedOptions'; +import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings'; +import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel'; +import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType'; +import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon'; +import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useLingui } from '@lingui/react/macro'; +import { useIcons } from 'twenty-ui/display'; +import { type SelectOption } from 'twenty-ui/input'; + +type WorkflowEditTriggerManualProps = { + trigger: WorkflowManualTrigger; + triggerOptions: + | { + readonly: true; + onTriggerUpdate?: undefined; + } + | { + readonly?: false; + onTriggerUpdate: (trigger: WorkflowManualTrigger) => void; + }; +}; + +const StyledLabel = styled.span` + color: ${({ theme }) => theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin-bottom: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledDescription = styled.span` + color: ${({ theme }) => theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.sm}; + margin-top: ${({ theme }) => theme.spacing(0.25)}; +`; + +const StyledIconPickerContainer = styled.div` + display: flex; + flex-direction: column; +`; + +export const WorkflowEditTriggerManual = ({ + trigger, + triggerOptions, +}: WorkflowEditTriggerManualProps) => { + const theme = useTheme(); + + const { t } = useLingui(); + + const { getIcon } = useIcons(); + + const { activeNonSystemObjectMetadataItems } = + useFilteredObjectMetadataItems(); + + const availableMetadata: Array> = + activeNonSystemObjectMetadataItems.map((item) => ({ + label: item.labelPlural, + value: item.nameSingular, + Icon: getIcon(item.icon), + })); + + const availability = trigger.settings.availability; + + const headerTitle = trigger.name ?? getTriggerDefaultLabel(trigger); + + const headerIcon = getTriggerIcon(trigger); + + const headerType = getTriggerHeaderType(trigger); + + const availabilityDescriptions = { + SINGLE_RECORD: t`The selected record will be passed to your workflow`, + BULK_RECORDS: t`The selected records will be passed to your workflow`, + GLOBAL: t`No record is required to trigger this workflow`, + }; + + return ( + <> + { + if (triggerOptions.readonly === true) { + return; + } + + triggerOptions.onTriggerUpdate({ + ...trigger, + name: newName, + }); + }} + Icon={getIcon(headerIcon)} + iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })} + initialTitle={headerTitle} + headerType={headerType} + disabled={triggerOptions.readonly} + /> + + { + if (triggerOptions.readonly === true || !availability) { + return; + } + + triggerOptions.onTriggerUpdate({ + ...trigger, + settings: { + ...trigger.settings, + availability: { + type: availability.type, + objectNameSingular, + }, + outputSchema: {}, + }, + }); + }} + dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }} + dropdownWidth={GenericDropdownContentWidth.ExtraLarge} + /> + ) : null} + + { + if (triggerOptions.readonly === true) { + e.stopPropagation(); + e.preventDefault(); + } + }} + > + {t`Command Icon`} + + {t`The icon your workflow trigger will display in the command menu`} + + } + onChange={({ iconKey }) => { + if (triggerOptions.readonly === true) { + return; + } + + triggerOptions.onTriggerUpdate({ + ...trigger, + settings: { + ...trigger.settings, + icon: iconKey, + }, + }); + }} + /> + +