Scaffold empty workflow (#6926)

- Create a workflow version when the user visits an empty workflow.
- If the trigger is not defined yet and the user selects either the
standard object type or the event type first, we automatically select
the first option of the other value. Indeed, every state update is
automatically saved on the backend and we need both standard object and
event types to save the event name.
- Introduces a change in the backend. I removed the assertions that
throw when a workflow version is not complete, that is, when it doesn't
have a defined trigger, which is the case when scaffolding a new
workflow with a first empty workflow version.
- We should keep validating the workflow versions, at least when we
publish them. That should be done in a second step.
This commit is contained in:
Baptiste Devessier 2024-09-12 17:01:10 +02:00 committed by GitHub
parent 3c4168759a
commit 3548751be2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 548 additions and 269 deletions

View File

@ -23,7 +23,10 @@ const getStepDefinitionOrThrow = ({
if (stepId === TRIGGER_STEP_ID) {
if (!isDefined(currentVersion.trigger)) {
throw new Error('Expected to find the definition of the trigger');
return {
type: 'trigger',
definition: undefined,
} as const;
}
return {
@ -33,7 +36,9 @@ const getStepDefinitionOrThrow = ({
}
if (!isDefined(currentVersion.steps)) {
throw new Error('Expected to find an array of steps');
throw new Error(
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
);
}
const selectedNodePosition = findStepPositionOrThrow({
@ -74,7 +79,7 @@ export const RightDrawerWorkflowEditStepContent = ({
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
onUpdateTrigger={updateTrigger}
onTriggerUpdate={updateTrigger}
/>
);
}
@ -82,7 +87,7 @@ export const RightDrawerWorkflowEditStepContent = ({
return (
<WorkflowEditActionForm
action={stepDefinition.definition}
onUpdateAction={updateStep}
onActionUpdate={updateStep}
/>
);
};

View File

@ -1,6 +1,6 @@
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { WorkflowDiagramCanvas } from '@/workflow/components/WorkflowDiagramCanvas';
import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect';
import { WorkflowEffect } from '@/workflow/components/WorkflowEffect';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import styled from '@emotion/styled';
import '@xyflow/react/dist/style.css';
@ -36,7 +36,7 @@ export const Workflow = ({
return (
<>
<WorkflowShowPageEffect workflowId={workflowId} />
<WorkflowEffect workflowId={workflowId} />
<StyledFlowContainer>
{workflowDiagram === undefined ? null : (

View File

@ -0,0 +1,109 @@
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import React from 'react';
import { capitalize } from '~/utils/string/capitalize';
type Variant = 'placeholder';
const StyledStepNodeContainer = styled.div`
display: flex;
flex-direction: column;
padding-bottom: 12px;
padding-top: 6px;
`;
const StyledStepNodeType = styled.div`
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm} 0 0;
color: ${({ theme }) => theme.color.gray50};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
position: absolute;
top: 0;
transform: translateY(-100%);
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
}
`;
const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-style: ${({ variant }) =>
variant === 'placeholder' ? 'dashed' : null};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
box-shadow: ${({ variant, theme }) =>
variant === 'placeholder' ? 'none' : theme.boxShadow.superHeavy};
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue10};
border-color: ${({ theme }) => theme.color.blue};
}
`;
const StyledStepNodeLabel = styled.div<{ variant?: Variant }>`
align-items: center;
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
column-gap: ${({ theme }) => theme.spacing(2)};
color: ${({ variant, theme }) =>
variant === 'placeholder' ? theme.font.color.extraLight : null};
`;
const StyledSourceHandle = styled(Handle)`
background-color: ${({ theme }) => theme.color.gray50};
`;
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
export const WorkflowDiagramBaseStepNode = ({
nodeType,
label,
variant,
Icon,
}: {
nodeType: WorkflowDiagramStepNodeData['nodeType'];
label: string;
variant?: Variant;
Icon?: React.ReactNode;
}) => {
return (
<StyledStepNodeContainer>
{nodeType !== 'trigger' ? (
<StyledTargetHandle type="target" position={Position.Top} />
) : null}
<StyledStepNodeInnerContainer variant={variant}>
<StyledStepNodeType>{capitalize(nodeType)}</StyledStepNodeType>
<StyledStepNodeLabel variant={variant}>
{Icon}
{label}
</StyledStepNodeLabel>
</StyledStepNodeInnerContainer>
<StyledSourceHandle type="source" position={Position.Bottom} />
</StyledStepNodeContainer>
);
};

View File

@ -1,5 +1,6 @@
import { WorkflowDiagramCanvasEffect } from '@/workflow/components/WorkflowDiagramCanvasEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNode } from '@/workflow/components/WorkflowDiagramStepNode';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import {
@ -72,6 +73,7 @@ export const WorkflowDiagramCanvas = ({
nodeTypes={{
default: WorkflowDiagramStepNode,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
fitView
nodes={nodes.map((node) => ({ ...node, draggable: false }))}

View File

@ -0,0 +1,30 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramEmptyTrigger = () => {
const theme = useTheme();
return (
<WorkflowDiagramBaseStepNode
label="Add a Trigger"
nodeType="trigger"
variant="placeholder"
Icon={
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd size={16} color={theme.font.color.tertiary} />
</StyledStepNodeLabelIconContainer>
}
/>
);
};

View File

@ -1,67 +1,16 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, Position } from '@xyflow/react';
import { IconCode, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeContainer = styled.div`
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
flex-direction: column;
padding-bottom: 12px;
padding-top: 6px;
`;
const StyledStepNodeType = styled.div`
background-color: ${({ theme }) => theme.background.tertiary};
border-radius: ${({ theme }) => theme.border.radius.sm}
${({ theme }) => theme.border.radius.sm} 0 0;
color: ${({ theme }) => theme.color.gray50};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
position: absolute;
top: 0;
transform: translateY(-100%);
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
}
`;
const StyledStepNodeInnerContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
box-shadow: ${({ theme }) => theme.boxShadow.superHeavy};
.selectable.selected &,
.selectable:focus &,
.selectable:focus-visible & {
background-color: ${({ theme }) => theme.color.blue10};
border-color: ${({ theme }) => theme.color.blue};
}
`;
const StyledStepNodeLabel = styled.div`
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledSourceHandle = styled(Handle)`
background-color: ${({ theme }) => theme.color.gray50};
`;
export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramStepNode = ({
@ -69,19 +18,37 @@ export const WorkflowDiagramStepNode = ({
}: {
data: WorkflowDiagramStepNodeData;
}) => {
const theme = useTheme();
const renderStepIcon = () => {
switch (data.nodeType) {
case 'trigger': {
return (
<StyledStepNodeLabelIconContainer>
<IconPlaylistAdd
size={theme.icon.size.sm}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'action': {
return (
<StyledStepNodeLabelIconContainer>
<IconCode size={theme.icon.size.sm} color={theme.color.orange} />
</StyledStepNodeLabelIconContainer>
);
}
}
return null;
};
return (
<StyledStepNodeContainer>
{data.nodeType !== 'trigger' ? (
<StyledTargetHandle type="target" position={Position.Top} />
) : null}
<StyledStepNodeInnerContainer>
<StyledStepNodeType>{data.nodeType}</StyledStepNodeType>
<StyledStepNodeLabel>{data.label}</StyledStepNodeLabel>
</StyledStepNodeInnerContainer>
<StyledSourceHandle type="source" position={Position.Bottom} />
</StyledStepNodeContainer>
<WorkflowDiagramBaseStepNode
nodeType={data.nodeType}
label={data.label}
Icon={renderStepIcon()}
/>
);
};

View File

@ -45,10 +45,10 @@ const StyledTriggerSettings = styled.div`
export const WorkflowEditActionForm = ({
action,
onUpdateAction,
onActionUpdate,
}: {
action: WorkflowAction;
onUpdateAction: (trigger: WorkflowAction) => void;
onActionUpdate: (trigger: WorkflowAction) => void;
}) => {
const theme = useTheme();
@ -88,7 +88,7 @@ export const WorkflowEditActionForm = ({
value={action.settings.serverlessFunctionId}
options={availableFunctions}
onChange={(updatedFunction) => {
onUpdateAction({
onActionUpdate({
...action,
settings: {
...action.settings,

View File

@ -47,39 +47,35 @@ const StyledTriggerSettings = styled.div`
export const WorkflowEditTriggerForm = ({
trigger,
onUpdateTrigger,
onTriggerUpdate,
}: {
trigger: WorkflowTrigger;
onUpdateTrigger: (trigger: WorkflowTrigger) => void;
trigger: WorkflowTrigger | undefined;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
}) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
const triggerEvent = isDefined(trigger)
? splitWorkflowTriggerEventName(trigger.settings.eventName)
: undefined;
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
}));
const recordTypeMetadata = activeObjectMetadataItems.find(
(item) => item.nameSingular === triggerEvent.objectType,
);
if (!isDefined(recordTypeMetadata)) {
throw new Error(
'Expected to find the metadata configuration for the currently selected record type of the trigger.',
);
}
const recordTypeMetadata = isDefined(triggerEvent)
? activeObjectMetadataItems.find(
(item) => item.nameSingular === triggerEvent.objectType,
)
: undefined;
const selectedEvent = OBJECT_EVENT_TRIGGERS.find(
(availableEvent) => availableEvent.value === triggerEvent.event,
);
if (!isDefined(selectedEvent)) {
throw new Error('Expected to find the currently selected event type.');
}
const selectedEvent = isDefined(triggerEvent)
? OBJECT_EVENT_TRIGGERS.find(
(availableEvent) => availableEvent.value === triggerEvent.event,
)
: undefined;
return (
<>
@ -89,11 +85,15 @@ export const WorkflowEditTriggerForm = ({
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
When a {recordTypeMetadata.labelSingular} is {selectedEvent.label}
{isDefined(recordTypeMetadata) && isDefined(selectedEvent)
? `When a ${recordTypeMetadata.labelSingular} is ${selectedEvent.label}`
: '-'}
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>
Trigger . Record is {selectedEvent.label}
{isDefined(selectedEvent)
? `Trigger . Record is ${selectedEvent.label}`
: '-'}
</StyledTriggerHeaderType>
</StyledTriggerHeader>
@ -102,32 +102,50 @@ export const WorkflowEditTriggerForm = ({
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
value={triggerEvent.objectType}
value={triggerEvent?.objectType}
options={availableMetadata}
onChange={(updatedRecordType) => {
onUpdateTrigger({
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
});
onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
}
: {
type: 'DATABASE_EVENT',
settings: {
eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`,
},
},
);
}}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent.event}
value={triggerEvent?.event}
options={OBJECT_EVENT_TRIGGERS}
onChange={(updatedEvent) => {
onUpdateTrigger({
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
});
onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
}
: {
type: 'DATABASE_EVENT',
settings: {
eventName: `${availableMetadata[0].value}.${updatedEvent}`,
},
},
);
}}
/>
</StyledTriggerSettings>

View File

@ -7,13 +7,11 @@ import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
type WorkflowShowPageEffectProps = {
type WorkflowEffectProps = {
workflowId: string;
};
export const WorkflowShowPageEffect = ({
workflowId,
}: WorkflowShowPageEffectProps) => {
export const WorkflowEffect = ({ workflowId }: WorkflowEffectProps) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const setWorkflowId = useSetRecoilState(workflowIdState);

View File

@ -1,30 +0,0 @@
import { PageHeader } from '@/ui/layout/page/PageHeader';
import { useNavigate } from 'react-router-dom';
import { IconComponent } from 'twenty-ui';
export const WorkflowShowPageHeader = ({
workflowName,
headerIcon,
children,
}: {
workflowName: string;
headerIcon: IconComponent;
children?: React.ReactNode;
}) => {
const navigate = useNavigate();
return (
<PageHeader
hasClosePageButton
onClosePage={() => {
navigate({
pathname: '/objects/workflows',
});
}}
title={workflowName}
Icon={headerIcon}
>
{children}
</PageHeader>
);
};

View File

@ -6,6 +6,7 @@ import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
export const useWorkflowWithCurrentVersion = (
@ -22,34 +23,37 @@ export const useWorkflowWithCurrentVersion = (
skip: !isDefined(workflowId),
});
const { records: mostRecentWorkflowVersions } =
useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowId,
},
const {
records: mostRecentWorkflowVersions,
loading: loadingMostRecentWorkflowVersions,
} = useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowId,
},
orderBy: [
{
createdAt: 'DescNullsLast',
},
],
limit: 1,
skip: !isDefined(workflowId),
});
},
orderBy: [
{
createdAt: 'DescNullsLast',
},
],
limit: 1,
skip: !isDefined(workflowId),
});
if (!isDefined(workflow)) {
return undefined;
}
const workflowWithCurrentVersion = useMemo(() => {
if (!isDefined(workflow) || loadingMostRecentWorkflowVersions) {
return undefined;
}
const currentVersion = mostRecentWorkflowVersions?.[0];
if (!isDefined(currentVersion)) {
return undefined;
}
const currentVersion = mostRecentWorkflowVersions?.[0];
return {
...workflow,
currentVersion,
};
return {
...workflow,
currentVersion,
};
}, [loadingMostRecentWorkflowVersions, mostRecentWorkflowVersions, workflow]);
return workflowWithCurrentVersion;
};

View File

@ -7,7 +7,7 @@ describe('getWorkflowVersionDiagram', () => {
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow version has no trigger', () => {
it('returns a diagram with an empty-trigger node if the provided workflow version has no trigger', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
@ -20,10 +20,20 @@ describe('getWorkflowVersionDiagram', () => {
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
expect(result).toEqual({
nodes: [
{
data: {},
id: 'trigger',
position: { x: 0, y: 0 },
type: 'empty-trigger',
},
],
edges: [],
});
});
it('returns an empty diagram if the provided workflow version has no steps', () => {
it('returns a diagram with an empty-trigger node if the provided workflow version has no steps', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
@ -39,7 +49,19 @@ describe('getWorkflowVersionDiagram', () => {
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
expect(result).toEqual({
nodes: [
{
data: {
label: 'Company is Created',
nodeType: 'trigger',
},
id: 'trigger',
position: { x: 0, y: 0 },
},
],
edges: [],
});
});
it('returns the diagram for the last version', () => {

View File

@ -7,6 +7,7 @@ import {
} from '@/workflow/types/WorkflowDiagram';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { MarkerType } from '@xyflow/react';
import { isDefined } from 'twenty-ui';
import { v4 } from 'uuid';
import { capitalize } from '~/utils/string/capitalize';
@ -14,7 +15,7 @@ export const generateWorkflowDiagram = ({
trigger,
steps,
}: {
trigger: WorkflowTrigger;
trigger: WorkflowTrigger | undefined;
steps: Array<WorkflowStep>;
}): WorkflowDiagram => {
const nodes: Array<WorkflowDiagramNode> = [];
@ -55,20 +56,34 @@ export const generateWorkflowDiagram = ({
// Start with the trigger node
const triggerNodeId = TRIGGER_STEP_ID;
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
},
position: {
x: 0,
y: 0,
},
});
if (isDefined(trigger)) {
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
},
position: {
x: 0,
y: 0,
},
});
} else {
nodes.push({
id: triggerNodeId,
type: 'empty-trigger',
data: {} as any,
position: {
x: 0,
y: 0,
},
});
}
let lastStepId = triggerNodeId;

View File

@ -11,18 +11,12 @@ const EMPTY_DIAGRAM: WorkflowDiagram = {
export const getWorkflowVersionDiagram = (
workflowVersion: WorkflowVersion | undefined,
): WorkflowDiagram => {
if (
!(
isDefined(workflowVersion) &&
isDefined(workflowVersion.trigger) &&
isDefined(workflowVersion.steps)
)
) {
if (!isDefined(workflowVersion)) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
trigger: workflowVersion.trigger ?? undefined,
steps: workflowVersion.steps ?? [],
});
};

View File

@ -9,3 +9,11 @@ export interface WorkspaceQueryHookInstance {
payload: ResolverArgs,
): Promise<ResolverArgs>;
}
export interface WorkspaceQueryPostHookInstance {
execute(
authContext: AuthContext,
objectName: string,
payload: unknown[],
): Promise<void>;
}

View File

@ -66,4 +66,25 @@ export class WorkspaceQueryHookStorage {
this.postHookInstances.get(key)?.push(data);
}
getWorkspaceQueryPostHookInstances(
key: WorkspaceQueryHookKey,
): WorkspaceQueryHookData<WorkspaceQueryHookInstance>[] {
const methodName = key.split('.')?.[1] as
| WorkspaceResolverBuilderMethodNames
| undefined;
let wildcardInstances: WorkspaceQueryHookData<WorkspaceQueryHookInstance>[] =
[];
if (!methodName) {
throw new Error(`Can't split workspace query hook key: ${key}`);
}
// Retrive wildcard post-hook instances
if (this.postHookInstances.has(`*.${methodName}`)) {
wildcardInstances = this.postHookInstances.get(`*.${methodName}`)!;
}
return [...wildcardInstances, ...(this.postHookInstances.get(key) ?? [])];
}
}

View File

@ -2,12 +2,13 @@ import { Injectable } from '@nestjs/common';
import merge from 'lodash.merge';
import { Record as IRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/record.interface';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage';
import { WorkspaceQueryHookKey } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookExplorer } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer';
import { WorkspaceQueryHookStorage } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/storage/workspace-query-hook.storage';
import { WorkspacePreQueryHookPayload } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { WorkspaceQueryHookExplorer } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.explorer';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@Injectable()
@ -49,4 +50,32 @@ export class WorkspaceQueryHookService {
return payload;
}
public async executePostQueryHooks<
T extends WorkspaceResolverBuilderMethodNames,
Record extends IRecord = IRecord,
>(
authContext: AuthContext,
// TODO: We should allow wildcard for object name
objectName: string,
methodName: T,
payload: Record[],
): Promise<void> {
const key: WorkspaceQueryHookKey = `${objectName}.${methodName}`;
const postHookInstances =
this.workspaceQueryHookStorage.getWorkspaceQueryPostHookInstances(key);
if (!postHookInstances) {
return;
}
for (const postHookInstance of postHookInstances) {
await this.workspaceQueryHookExplorer.handleHook(
[authContext, objectName, payload],
postHookInstance.instance,
postHookInstance.host,
postHookInstance.isRequestScoped,
);
}
}
}

View File

@ -325,6 +325,13 @@ export class WorkspaceQueryRunnerService {
)
)?.records;
await this.workspaceQueryHookService.executePostQueryHooks(
authContext,
objectMetadataItem.nameSingular,
'createMany',
parsedResults,
);
await this.triggerWebhooks<Record>(
parsedResults,
CallWebhookJobsJobOperation.create,

View File

@ -0,0 +1,42 @@
import { WorkspaceQueryPostHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
@WorkspaceQueryHook({
key: `workflow.createMany`,
type: WorkspaceQueryHookType.PostHook,
})
export class WorkflowCreateManyPostQueryHook
implements WorkspaceQueryPostHookInstance
{
constructor(private readonly twentyORMManager: TwentyORMManager) {}
async execute(
_authContext: AuthContext,
_objectName: string,
payload: WorkflowWorkspaceEntity[],
): Promise<void> {
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
'workflowVersion',
);
await Promise.all(
payload.map((workflow) => {
return workflowVersionRepository.insert({
workflowId: workflow.id,
status: WorkflowVersionStatus.DRAFT,
name: 'v1',
});
}),
);
}
}

View File

@ -0,0 +1,40 @@
import { WorkspaceQueryPostHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface';
import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator';
import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
@WorkspaceQueryHook({
key: `workflow.createOne`,
type: WorkspaceQueryHookType.PostHook,
})
export class WorkflowCreateOnePostQueryHook
implements WorkspaceQueryPostHookInstance
{
constructor(private readonly twentyORMManager: TwentyORMManager) {}
async execute(
_authContext: AuthContext,
_objectName: string,
payload: WorkflowWorkspaceEntity[],
): Promise<void> {
const workflow = payload[0];
const workflowVersionRepository =
await this.twentyORMManager.getRepository<WorkflowVersionWorkspaceEntity>(
'workflowVersion',
);
await workflowVersionRepository.insert({
workflowId: workflow.id,
status: WorkflowVersionStatus.DRAFT,
name: 'v1',
});
}
}

View File

@ -1,19 +1,20 @@
import { Module } from '@nestjs/common';
import { WorkflowCreateManyPostQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook';
import { WorkflowCreateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-create-many.pre-query.hook';
import { WorkflowCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-create-one.pre-query.hook';
import { WorkflowRunCreateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-run-create-many.pre-query.hook';
import { WorkflowRunCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-run-create-one.pre-query.hook';
import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service';
import { WorkflowUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-many.pre-query.hook';
import { WorkflowUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-one.pre-query.hook';
import { WorkflowVersionCreateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-create-many.pre-query.hook';
import { WorkflowVersionCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-create-one.pre-query.hook';
import { WorkflowVersionDeleteManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-delete-many.pre-query.hook';
import { WorkflowVersionDeleteOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-delete-one.pre-query.hook';
import { WorkflowVersionUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-update-many.pre-query.hook';
import { WorkflowVersionUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-update-one.pre-query.hook';
import { WorkflowCreateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-create-many.pre-query.hook';
import { WorkflowCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-create-one.pre-query.hook';
import { WorkflowUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-many.pre-query.hook';
import { WorkflowUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-one.pre-query.hook';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-version-validation.workspace-service';
@Module({
providers: [
@ -29,6 +30,7 @@ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/work
WorkflowVersionUpdateManyPreQueryHook,
WorkflowVersionDeleteOnePreQueryHook,
WorkflowVersionDeleteManyPreQueryHook,
WorkflowCreateManyPostQueryHook,
WorkflowVersionValidationWorkspaceService,
WorkflowCommonWorkspaceService,
],

View File

@ -6,12 +6,9 @@ import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
export const assertWorkflowVersionIsDraft = (
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
) => {
if (workflowVersion.status !== WorkflowVersionStatus.DRAFT) {
throw new WorkflowQueryValidationException(

View File

@ -0,0 +1,22 @@
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
export function assertWorkflowVersionTriggerIsDefined(
workflowVersion: WorkflowVersionWorkspaceEntity,
): asserts workflowVersion is Omit<
WorkflowVersionWorkspaceEntity,
'trigger'
> & {
trigger: WorkflowTrigger;
} {
if (!workflowVersion.trigger) {
throw new WorkflowTriggerException(
'Workflow version does not contain trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
}

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
@ -12,11 +11,9 @@ import {
export class WorkflowCommonWorkspaceService {
constructor(private readonly twentyORMManager: TwentyORMManager) {}
async getWorkflowVersionOrFail(workflowVersionId: string): Promise<
Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
}
> {
async getWorkflowVersionOrFail(
workflowVersionId: string,
): Promise<WorkflowVersionWorkspaceEntity> {
if (!workflowVersionId) {
throw new WorkflowTriggerException(
'Workflow version ID is required',
@ -40,11 +37,7 @@ export class WorkflowCommonWorkspaceService {
async getValidWorkflowVersionOrFail(
workflowVersion: WorkflowVersionWorkspaceEntity | null,
): Promise<
Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
}
> {
): Promise<WorkflowVersionWorkspaceEntity> {
if (!workflowVersion) {
throw new WorkflowTriggerException(
'Workflow version not found',
@ -52,12 +45,13 @@ export class WorkflowCommonWorkspaceService {
);
}
if (!workflowVersion.trigger) {
throw new WorkflowTriggerException(
'Workflow version does not contains trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
// FIXME: For now we will make the trigger optional. Later, we'll have to ensure the trigger is defined when publishing the flow.
// if (!workflowVersion.trigger) {
// throw new WorkflowTriggerException(
// 'Workflow version does not contains trigger',
// WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
// );
// }
return { ...workflowVersion, trigger: workflowVersion.trigger };
}

View File

@ -3,19 +3,14 @@ import {
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import {
WorkflowTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
export function assertVersionCanBeActivated(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
workflow: WorkflowWorkspaceEntity,
) {
assertVersionIsValid(workflowVersion);
@ -37,11 +32,7 @@ export function assertVersionCanBeActivated(
}
}
function assertVersionIsValid(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
) {
function assertVersionIsValid(workflowVersion: WorkflowVersionWorkspaceEntity) {
if (!workflowVersion.trigger) {
throw new WorkflowTriggerException(
'Workflow version does not contain trigger',

View File

@ -8,24 +8,22 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import {
WorkflowVersionStatus,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import {
WorkflowTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { WorkflowRunnerWorkspaceService } from 'src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service';
import { WorkflowVersionStatusUpdate } from 'src/modules/workflow/workflow-status/jobs/workflow-statuses-update.job';
import { DatabaseEventTriggerService } from 'src/modules/workflow/workflow-trigger/database-event-trigger/database-event-trigger.service';
import { assertVersionCanBeActivated } from 'src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { assertVersionCanBeActivated } from 'src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util';
@Injectable()
export class WorkflowTriggerWorkspaceService {
@ -158,9 +156,7 @@ export class WorkflowTriggerWorkspaceService {
private async performActivationSteps(
workflow: WorkflowWorkspaceEntity,
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowRepository: WorkspaceRepository<WorkflowWorkspaceEntity>,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
manager: EntityManager,
@ -217,9 +213,7 @@ export class WorkflowTriggerWorkspaceService {
}
private async setActiveVersionStatus(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
manager: EntityManager,
) {
@ -254,9 +248,7 @@ export class WorkflowTriggerWorkspaceService {
}
private async setDeactivatedVersionStatus(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
workflowVersionRepository: WorkspaceRepository<WorkflowVersionWorkspaceEntity>,
manager: EntityManager,
) {
@ -307,11 +299,11 @@ export class WorkflowTriggerWorkspaceService {
}
private async enableTrigger(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
manager: EntityManager,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
switch (workflowVersion.trigger.type) {
case WorkflowTriggerType.DATABASE_EVENT:
await this.databaseEventTriggerService.createEventListener(
@ -326,11 +318,11 @@ export class WorkflowTriggerWorkspaceService {
}
private async disableTrigger(
workflowVersion: Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
},
workflowVersion: WorkflowVersionWorkspaceEntity,
manager: EntityManager,
) {
assertWorkflowVersionTriggerIsDefined(workflowVersion);
switch (workflowVersion.trigger.type) {
case WorkflowTriggerType.DATABASE_EVENT:
await this.databaseEventTriggerService.deleteEventListener(