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
This commit is contained in:
Thomas Trompette
2025-09-26 14:17:40 +02:00
committed by GitHub
parent 584389e17f
commit 2c34e1fb8a
52 changed files with 882 additions and 73 deletions
@@ -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<typeof workflowTriggerSchema>;
export type WorkflowTriggerType = WorkflowTrigger['type'];
@@ -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 (
<WorkflowEditTriggerManual
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={{
readonly: true,
}}
/>
);
}
return (
<WorkflowEditTriggerManualForm
<WorkflowEditTriggerManualDeprecated
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={{
@@ -17,9 +17,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 WorkflowStepDetailProps = {
stepId: string;
@@ -45,6 +48,9 @@ export const WorkflowStepDetail = ({
steps,
...props
}: WorkflowStepDetailProps) => {
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 (
<WorkflowEditTriggerManual
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={props}
/>
);
}
return (
<WorkflowEditTriggerManualForm
<WorkflowEditTriggerManualDeprecated
key={stepId}
trigger={stepDefinition.definition}
triggerOptions={props}
@@ -1,6 +1,6 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { isObject, isString } from '@sniptt/guards';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
import { type JsonValue } from 'type-fest';
function* resolveVariables(value: JsonValue): Generator<string> {
@@ -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,
@@ -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';
@@ -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<SelectOption<string>> =
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 (
<>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
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}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
label={t`Availability`}
description={
availability?.type
? availabilityDescriptions[availability.type]
: undefined
}
fullWidth
disabled={triggerOptions.readonly}
value={availability?.type}
options={MANUAL_TRIGGER_AVAILABILITY_TYPE_OPTIONS}
onChange={(availabilityType) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
availabilityType,
activeNonSystemObjectMetadataItems,
icon: trigger.settings.icon,
isPinned: trigger.settings.isPinned,
}),
});
}}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{availability?.type === 'SINGLE_RECORD' ||
availability?.type === 'BULK_RECORDS' ? (
<Select
dropdownId="workflow-edit-manual-trigger-object"
label={t`Object`}
description={t`On which object(s) should this trigger be available`}
fullWidth
value={availability?.objectNameSingular}
options={availableMetadata}
disabled={triggerOptions.readonly}
onChange={(objectNameSingular) => {
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}
<IconPicker
dropdownId="workflow-edit-manual-trigger-icon"
selectedIconKey={trigger.settings.icon}
dropdownOffset={{ y: -parseInt(theme.spacing(3), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
maxIconsVisible={9 * 8} // 9 columns * 8 lines
disabled={triggerOptions.readonly}
clickableComponent={
<StyledIconPickerContainer
onClick={(e) => {
if (triggerOptions.readonly === true) {
e.stopPropagation();
e.preventDefault();
}
}}
>
<StyledLabel>{t`Command Icon`}</StyledLabel>
<SelectControl
isDisabled={triggerOptions.readonly}
selectedOption={{
Icon: getIcon(trigger.settings.icon),
value: trigger.settings.icon || null,
label: '',
}}
/>
<StyledDescription>{t`The icon your workflow trigger will display in the command menu`}</StyledDescription>
</StyledIconPickerContainer>
}
onChange={({ iconKey }) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
icon: iconKey,
},
});
}}
/>
<Select
dropdownId="workflow-edit-manual-trigger-navbar"
label={t`Navbar`}
description={t`Display a button in the top navbar to trigger this workflow`}
fullWidth
value={trigger.settings.isPinned}
options={MANUAL_TRIGGER_IS_PINNED_OPTIONS}
disabled={triggerOptions.readonly}
onChange={(updatedValue) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
isPinned: updatedValue,
outputSchema: {},
},
});
}}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
</WorkflowStepBody>
</>
);
};
@@ -11,7 +11,7 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { MANUAL_TRIGGER_AVAILABILITY_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerAvailabilityOptions';
import { MANUAL_TRIGGER_IS_PINNED_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerIsPinnedOptions';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getManualTriggerDefaultSettingsDeprecated } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettingsDeprecated';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
@@ -23,7 +23,7 @@ import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
type WorkflowEditTriggerManualFormProps = {
type WorkflowEditTriggerManualDeprecatedProps = {
trigger: WorkflowManualTrigger;
triggerOptions:
| {
@@ -54,10 +54,10 @@ const StyledIconPickerContainer = styled.div`
flex-direction: column;
`;
export const WorkflowEditTriggerManualForm = ({
export const WorkflowEditTriggerManualDeprecated = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerManualFormProps) => {
}: WorkflowEditTriggerManualDeprecatedProps) => {
const theme = useTheme();
const { t } = useLingui();
@@ -168,7 +168,7 @@ export const WorkflowEditTriggerManualForm = ({
triggerOptions.onTriggerUpdate({
...trigger,
settings: getManualTriggerDefaultSettings({
settings: getManualTriggerDefaultSettingsDeprecated({
availability: updatedTriggerType,
activeNonSystemObjectMetadataItems,
icon: trigger.settings.icon,
@@ -0,0 +1,28 @@
import {
IconCheckbox,
type IconComponent,
IconId,
IconListDetails,
} from 'twenty-ui/display';
export const MANUAL_TRIGGER_AVAILABILITY_TYPE_OPTIONS: Array<{
label: string;
value: 'GLOBAL' | 'SINGLE_RECORD' | 'BULK_RECORDS';
Icon: IconComponent;
}> = [
{
label: 'Global',
value: 'GLOBAL',
Icon: IconCheckbox,
},
{
label: 'Single',
value: 'SINGLE_RECORD',
Icon: IconId,
},
{
label: 'Bulk',
value: 'BULK_RECORDS',
Icon: IconListDetails,
},
];
@@ -52,7 +52,10 @@ describe('useUpdateWorkflowVersionTrigger', () => {
});
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({ step: trigger });
expect(mockComputeStepOutputSchema).toHaveBeenCalledWith({
step: trigger,
workflowVersionId: 'version-id',
});
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
idToUpdate: 'version-id',
updateOneRecordInput: {
@@ -28,6 +28,7 @@ export const useUpdateWorkflowVersionTrigger = () => {
const outputSchema = (
await computeStepOutputSchema({
step: updatedTrigger,
workflowVersionId,
})
)?.data?.computeStepOutputSchema;
@@ -1,11 +1,11 @@
import { type WorkflowManualTriggerAvailability } from '@/workflow/types/Workflow';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { getManualTriggerDefaultSettings } from '../getManualTriggerDefaultSettings';
import { getManualTriggerDefaultSettingsDeprecated } from '../getManualTriggerDefaultSettingsDeprecated';
it('returns settings for a manual trigger that can be activated from any where', () => {
expect(
getManualTriggerDefaultSettings({
getManualTriggerDefaultSettingsDeprecated({
availability: 'EVERYWHERE',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
}),
@@ -19,7 +19,7 @@ it('returns settings for a manual trigger that can be activated from any where',
it('returns settings for a manual trigger that can be activated from any where', () => {
expect(
getManualTriggerDefaultSettings({
getManualTriggerDefaultSettingsDeprecated({
availability: 'WHEN_RECORD_SELECTED',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
icon: 'IconTest',
@@ -34,7 +34,7 @@ it('returns settings for a manual trigger that can be activated from any where',
it('returns settings for WHEN_RECORD_SELECTED with default icon when no custom icon provided', () => {
expect(
getManualTriggerDefaultSettings({
getManualTriggerDefaultSettingsDeprecated({
availability: 'WHEN_RECORD_SELECTED',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
}),
@@ -51,7 +51,7 @@ it('throws error for unsupported availability type', () => {
'INVALID_AVAILABILITY' as WorkflowManualTriggerAvailability;
expect(() =>
getManualTriggerDefaultSettings({
getManualTriggerDefaultSettingsDeprecated({
availability: invalidAvailability,
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
}),
@@ -10,6 +10,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_CREATED,
type: 'DATABASE_EVENT',
activeNonSystemObjectMetadataItems: [],
isIteratorEnabled: false,
});
}).toThrow();
});
@@ -20,6 +21,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_CREATED,
type: 'DATABASE_EVENT',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'DATABASE_EVENT',
@@ -41,6 +43,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_UPDATED,
type: 'DATABASE_EVENT',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'DATABASE_EVENT',
@@ -62,6 +65,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_DELETED,
type: 'DATABASE_EVENT',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'DATABASE_EVENT',
@@ -83,6 +87,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_CREATED,
type: 'DATABASE_EVENT',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'DATABASE_EVENT',
@@ -104,6 +109,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: 'Launch manually',
type: 'MANUAL',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'MANUAL',
@@ -127,6 +133,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: 'On a schedule',
type: 'CRON',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'CRON',
@@ -149,6 +156,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: 'Webhook',
type: 'WEBHOOK',
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
}),
).toStrictEqual({
type: 'WEBHOOK',
@@ -171,6 +179,7 @@ describe('getTriggerDefaultDefinition', () => {
defaultLabel: DatabaseTriggerDefaultLabel.RECORD_IS_CREATED,
type: 'unknown' as any,
activeNonSystemObjectMetadataItems: generatedMockObjectMetadataItems,
isIteratorEnabled: false,
});
}).toThrow('Unknown type: unknown');
});
@@ -1,40 +1,57 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type WorkflowManualTriggerAvailability,
type WorkflowManualTriggerSettings,
} from '@/workflow/types/Workflow';
import { type WorkflowManualTriggerSettings } from '@/workflow/types/Workflow';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { assertUnreachable } from 'twenty-shared/utils';
export const getManualTriggerDefaultSettings = ({
availability,
availabilityType,
activeNonSystemObjectMetadataItems,
icon,
isPinned,
}: {
availability: WorkflowManualTriggerAvailability;
availabilityType: 'GLOBAL' | 'SINGLE_RECORD' | 'BULK_RECORDS';
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
icon?: string;
isPinned?: boolean;
}): WorkflowManualTriggerSettings => {
switch (availability) {
case 'EVERYWHERE': {
switch (availabilityType) {
case 'GLOBAL': {
return {
objectType: undefined,
availability: {
type: 'GLOBAL',
locations: undefined,
},
outputSchema: {},
icon: icon || COMMAND_MENU_DEFAULT_ICON,
isPinned: isPinned || false,
};
}
case 'WHEN_RECORD_SELECTED': {
case 'SINGLE_RECORD': {
return {
objectType: activeNonSystemObjectMetadataItems[0].nameSingular,
availability: {
type: 'SINGLE_RECORD',
objectNameSingular:
activeNonSystemObjectMetadataItems[0].nameSingular,
},
outputSchema: {},
icon: icon || COMMAND_MENU_DEFAULT_ICON,
isPinned: isPinned || false,
};
}
case 'BULK_RECORDS': {
return {
availability: {
type: 'BULK_RECORDS',
objectNameSingular:
activeNonSystemObjectMetadataItems[0].nameSingular,
},
outputSchema: {},
icon: icon || COMMAND_MENU_DEFAULT_ICON,
isPinned: isPinned || false,
};
}
default: {
return assertUnreachable(availabilityType);
}
}
return assertUnreachable(availability);
};
@@ -0,0 +1,40 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type WorkflowManualTriggerAvailability,
type WorkflowManualTriggerSettings,
} from '@/workflow/types/Workflow';
import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon';
import { assertUnreachable } from 'twenty-shared/utils';
export const getManualTriggerDefaultSettingsDeprecated = ({
availability,
activeNonSystemObjectMetadataItems,
icon,
isPinned,
}: {
availability: WorkflowManualTriggerAvailability;
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
icon?: string;
isPinned?: boolean;
}): WorkflowManualTriggerSettings => {
switch (availability) {
case 'EVERYWHERE': {
return {
objectType: undefined,
outputSchema: {},
icon: icon || COMMAND_MENU_DEFAULT_ICON,
isPinned: isPinned || false,
};
}
case 'WHEN_RECORD_SELECTED': {
return {
objectType: activeNonSystemObjectMetadataItems[0].nameSingular,
outputSchema: {},
icon: icon || COMMAND_MENU_DEFAULT_ICON,
isPinned: isPinned || false,
};
}
}
return assertUnreachable(availability);
};
@@ -5,6 +5,7 @@ import {
} from '@/workflow/types/Workflow';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getManualTriggerDefaultSettingsDeprecated } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettingsDeprecated';
import { assertUnreachable } from 'twenty-shared/utils';
// TODO: This needs to be migrated to the server
@@ -12,10 +13,12 @@ export const getTriggerDefaultDefinition = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
isIteratorEnabled,
}: {
defaultLabel: string;
type: WorkflowTriggerType;
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
isIteratorEnabled: boolean;
}): WorkflowTrigger => {
if (activeNonSystemObjectMetadataItems.length === 0) {
throw new Error(
@@ -44,10 +47,20 @@ export const getTriggerDefaultDefinition = ({
};
}
case 'MANUAL': {
if (isIteratorEnabled) {
return {
...baseTriggerDefinition,
type,
settings: getManualTriggerDefaultSettings({
availabilityType: 'GLOBAL',
activeNonSystemObjectMetadataItems,
}),
};
}
return {
...baseTriggerDefinition,
type,
settings: getManualTriggerDefaultSettings({
settings: getManualTriggerDefaultSettingsDeprecated({
availability: 'WHEN_RECORD_SELECTED',
activeNonSystemObjectMetadataItems,
}),
@@ -1 +0,0 @@
export const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;
@@ -0,0 +1,6 @@
import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
export type ManualTriggerOutputSchema =
| BaseOutputSchemaV2
| RecordOutputSchemaV2;
@@ -6,6 +6,7 @@ import { type BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/Bas
import { type CodeOutputSchema } from '@/workflow/workflow-variables/types/CodeOutputSchema';
import { type FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { type FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
export type OutputSchemaV2 =
@@ -13,7 +14,8 @@ export type OutputSchemaV2 =
| CodeOutputSchema
| FindRecordsOutputSchema
| FormOutputSchema
| RecordOutputSchemaV2;
| RecordOutputSchemaV2
| ManualTriggerOutputSchema;
export type StepOutputSchemaV2 = {
id: string;
@@ -0,0 +1,13 @@
import {
type WorkflowActionType,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
export const isManualTriggerOutputSchema = (
stepType: WorkflowActionType | WorkflowTriggerType,
schema: OutputSchemaV2,
): schema is ManualTriggerOutputSchema => {
return stepType === 'MANUAL';
};
@@ -5,12 +5,7 @@ import {
import { type RecordActionOutputSchema } from '@/workflow/workflow-variables/types/RecordActionOutputSchema';
import { type OutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
const RECORD_STEP_TYPES = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'MANUAL',
];
const RECORD_STEP_TYPES = ['CREATE_RECORD', 'UPDATE_RECORD', 'DELETE_RECORD'];
export const isRecordStepOutputSchema = (
stepType: WorkflowActionType | WorkflowTriggerType,
@@ -1,4 +1,4 @@
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { extractRawVariableNamePart } from 'twenty-shared/workflow';
describe('extractRawVariableNamePart', () => {
it('returns the last part of a properly formatted variable', () => {
@@ -1,32 +0,0 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { isDefined } from 'twenty-shared/utils';
export const extractRawVariableNamePart = ({
rawVariableName,
part,
}: {
rawVariableName: string;
part: 'stepId' | 'selectedField';
}) => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => {
return variableName;
},
);
const parts = variableWithoutBrackets.split('.');
const extractedPart =
part === 'stepId'
? parts.at(0)
: part === 'selectedField'
? parts.at(-1)
: null;
if (!isDefined(extractedPart)) {
throw new Error('Expected to find at least one splitted chunk.');
}
return extractedPart;
};
@@ -1,4 +1,4 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
export const removeVariablesFromJson = (json: string): string => {
return json.replaceAll(CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX, 'null');
@@ -1,7 +1,7 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { BaseOutputSchemaV2 } from '@/workflow/workflow-variables/types/BaseOutputSchemaV2';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
const parseVariableName = (rawVariableName: string) => {
const variableWithoutBrackets = rawVariableName.replace(
@@ -1,9 +1,9 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { FindRecordsOutputSchema } from '@/workflow/workflow-variables/types/FindRecordsOutputSchema';
import { searchRecordOutputSchema as searchRecordOutputSchemaUtil } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
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';
@@ -1,8 +1,8 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import type { FormOutputSchema } from '@/workflow/workflow-variables/types/FormOutputSchema';
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
/**
* Parses a variable name to extract its components for Form outputs
@@ -0,0 +1,32 @@
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
import { type ManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/ManualTriggerOutputSchema';
import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughBaseOutputSchema';
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
export const searchVariableThroughManualTriggerOutputSchema = ({
stepName,
manualTriggerOutputSchema,
rawVariableName,
isFullRecord,
}: {
stepName: string;
manualTriggerOutputSchema: ManualTriggerOutputSchema;
rawVariableName: string;
isFullRecord: boolean;
}) => {
if (isRecordOutputSchemaV2(manualTriggerOutputSchema)) {
return searchVariableThroughRecordOutputSchema({
stepName,
recordOutputSchema: manualTriggerOutputSchema,
rawVariableName,
isFullRecord,
});
}
return searchVariableThroughBaseOutputSchema({
stepName,
baseOutputSchema: manualTriggerOutputSchema,
rawVariableName,
isFullRecord,
});
};
@@ -6,6 +6,7 @@ import { isCodeOutputSchema } from '@/workflow/workflow-variables/types/guards/i
import { isDatabaseEventTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isDatabaseEventTriggerOutputSchema';
import { isFindRecordsOutputSchema } from '@/workflow/workflow-variables/types/guards/isFindRecordsOutputSchema';
import { isFormOutputSchema } from '@/workflow/workflow-variables/types/guards/isFormOutputSchema';
import { isManualTriggerOutputSchema } from '@/workflow/workflow-variables/types/guards/isManualTriggerOutputSchema';
import { isRecordStepOutputSchema } from '@/workflow/workflow-variables/types/guards/isRecordStepOutputSchema';
import { type StepOutputSchemaV2 } from '@/workflow/workflow-variables/types/StepOutputSchemaV2';
@@ -13,6 +14,7 @@ import { searchVariableThroughBaseOutputSchema } from '@/workflow/workflow-varia
import { searchVariableThroughCodeOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughCodeOutputSchema';
import { searchVariableThroughFindRecordsOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFindRecordsOutputSchema';
import { searchVariableThroughFormOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughFormOutputSchema';
import { searchVariableThroughManualTriggerOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughManualTriggerOutputSchema';
import { searchVariableThroughRecordEventOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordEventOutputSchema';
import { searchVariableThroughRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
@@ -36,6 +38,15 @@ export const searchVariableThroughOutputSchemaV2 = ({
});
}
if (isManualTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)) {
return searchVariableThroughManualTriggerOutputSchema({
stepName: stepOutputSchema.name,
manualTriggerOutputSchema: stepOutputSchema.outputSchema,
rawVariableName,
isFullRecord,
});
}
if (
isDatabaseEventTriggerOutputSchema(stepType, stepOutputSchema.outputSchema)
) {
@@ -1,8 +1,8 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import { type RecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { searchRecordOutputSchema } from '@/workflow/workflow-variables/utils/searchVariableThroughRecordOutputSchema';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
/**
* Parses a variable name to extract its components
@@ -1,4 +1,3 @@
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from '@/workflow/workflow-variables/constants/CaptureAllVariableTagInnerRegex';
import { type VariableSearchResult } from '@/workflow/workflow-variables/hooks/useSearchVariable';
import {
type FieldOutputSchemaV2,
@@ -7,6 +6,7 @@ import {
} from '@/workflow/workflow-variables/types/RecordOutputSchemaV2';
import { isRecordOutputSchemaV2 } from '@/workflow/workflow-variables/types/guards/isRecordOutputSchemaV2';
import { isDefined } from 'twenty-shared/utils';
import { CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX } from 'twenty-shared/workflow';
const getRecordObjectLabel = (
recordSchema: RecordOutputSchemaV2,
@@ -1,7 +1,7 @@
import { WorkflowTextEditorVariableChip } from '@/workflow/workflow-variables/components/WorkflowTextEditorVariableChip';
import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart';
import { Node } from '@tiptap/core';
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
import { extractRawVariableNamePart } from 'twenty-shared/workflow';
declare module '@tiptap/core' {
interface Commands<ReturnType> {