Refacto workflow folders (#9302)

- Create separated folders for sections
- Add components
- Add utils and clean old ones
- Add constants
- Rename search variables folder and components

Next steps:
- clean hooks
- clean states
This commit is contained in:
Thomas Trompette
2024-12-31 17:08:14 +01:00
committed by GitHub
parent d4d8883794
commit 9e74ffae52
109 changed files with 195 additions and 840 deletions

View File

@ -0,0 +1,57 @@
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
export const StyledSearchVariablesDropdownContainer = styled.div<{
multiline?: boolean;
readonly?: boolean;
}>`
align-items: center;
display: flex;
justify-content: center;
${({ theme, readonly }) =>
!readonly &&
css`
:hover {
background-color: ${theme.background.transparent.light};
}
`}
${({ theme, multiline }) =>
multiline
? css`
border-radius: ${theme.border.radius.sm};
padding: ${theme.spacing(0.5)} ${theme.spacing(0)};
position: absolute;
right: ${theme.spacing(0)};
top: ${theme.spacing(0)};
`
: css`
background-color: ${theme.background.transparent.lighter};
border-top-right-radius: ${theme.border.radius.sm};
border-bottom-right-radius: ${theme.border.radius.sm};
border: 1px solid ${theme.border.color.medium};
`}
`;
export const WorkflowVariablePicker: VariablePickerComponent = ({
inputId,
disabled,
multiline,
onVariableSelect,
}) => {
return (
<StyledSearchVariablesDropdownContainer
multiline={multiline}
readonly={disabled}
>
<WorkflowVariablesDropdown
inputId={inputId}
onVariableSelect={onVariableSelect}
disabled={disabled}
/>
</StyledSearchVariablesDropdownContainer>
);
};

View File

@ -0,0 +1,131 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { WorkflowVariablesDropdownFieldItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownFieldItems';
import { WorkflowVariablesDropdownObjectItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownObjectItems';
import { WorkflowVariablesDropdownWorkflowStepItems } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdownWorkflowStepItems';
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/workflow-variables/constants/SearchVariablesDropdownId';
import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep';
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconVariablePlus, isDefined } from 'twenty-ui';
const StyledDropdownVariableButtonContainer = styled(
StyledDropdownButtonContainer,
)<{ transparentBackground?: boolean; disabled?: boolean }>`
background-color: ${({ theme, transparentBackground }) =>
transparentBackground
? 'transparent'
: theme.background.transparent.lighter};
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
:hover {
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
}
`;
export const WorkflowVariablesDropdown = ({
inputId,
onVariableSelect,
disabled,
objectNameSingularToSelect,
}: {
inputId: string;
onVariableSelect: (variableName: string) => void;
disabled?: boolean;
objectNameSingularToSelect?: string;
}) => {
const theme = useTheme();
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${inputId}`;
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
const availableVariablesInWorkflowStep = useAvailableVariablesInWorkflowStep({
objectNameSingularToSelect,
});
const initialStep =
availableVariablesInWorkflowStep.length === 1
? availableVariablesInWorkflowStep[0]
: undefined;
const [selectedStep, setSelectedStep] = useState<
StepOutputSchema | undefined
>(initialStep);
const handleStepSelect = (stepId: string) => {
setSelectedStep(
availableVariablesInWorkflowStep.find((step) => step.id === stepId),
);
};
const handleSubItemSelect = (subItem: string) => {
onVariableSelect(subItem);
setSelectedStep(undefined);
closeDropdown();
};
const handleBack = () => {
setSelectedStep(undefined);
};
if (disabled === true) {
return (
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
disabled={disabled}
transparentBackground
>
<IconVariablePlus
size={theme.icon.size.sm}
color={theme.font.color.light}
/>
</StyledDropdownVariableButtonContainer>
);
}
return (
<Dropdown
dropdownMenuWidth={320}
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<StyledDropdownVariableButtonContainer
isUnfolded={isDropdownOpen}
disabled={disabled}
transparentBackground
>
<IconVariablePlus size={theme.icon.size.sm} />
</StyledDropdownVariableButtonContainer>
}
dropdownComponents={
!isDefined(selectedStep) ? (
<WorkflowVariablesDropdownWorkflowStepItems
dropdownId={dropdownId}
steps={availableVariablesInWorkflowStep}
onSelect={handleStepSelect}
/>
) : isDefined(objectNameSingularToSelect) ? (
<WorkflowVariablesDropdownObjectItems
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
) : (
<WorkflowVariablesDropdownFieldItems
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
)
}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 2, y: 4 }}
/>
);
};

View File

@ -0,0 +1,162 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import {
BaseOutputSchema,
LinkOutputSchema,
OutputSchema,
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-actions/constants/WorkflowServerlessFunctionTabListComponentId';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
import {
IconChevronLeft,
isDefined,
MenuItemSelect,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
type WorkflowVariablesDropdownFieldItemsProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
export const WorkflowVariablesDropdownFieldItems = ({
step,
onSelect,
onBack,
}: WorkflowVariablesDropdownFieldItemsProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const { setActiveTabId } = useTabList(
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
workflowDiagramTriggerNodeSelectionState,
);
const getCurrentSubStep = (): OutputSchema => {
let currentSubStep = step.outputSchema;
for (const key of currentPath) {
if (isRecordOutputSchema(currentSubStep)) {
currentSubStep = currentSubStep.fields[key]?.value;
} else if (isBaseOutputSchema(currentSubStep)) {
currentSubStep = currentSubStep[key]?.value;
}
}
return currentSubStep;
};
const getDisplayedSubStepFields = () => {
const currentSubStep = getCurrentSubStep();
if (isLinkOutputSchema(currentSubStep)) {
return { link: currentSubStep.link };
} else if (isRecordOutputSchema(currentSubStep)) {
return currentSubStep.fields;
} else if (isBaseOutputSchema(currentSubStep)) {
return currentSubStep;
}
};
const handleSelectField = (key: string) => {
const currentSubStep = getCurrentSubStep();
const handleSelectBaseOutputSchema = (
baseOutputSchema: BaseOutputSchema,
) => {
if (!baseOutputSchema[key]?.isLeaf) {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
} else {
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
}
};
const handleSelectLinkOutputSchema = (
linkOutputSchema: LinkOutputSchema,
) => {
setWorkflowSelectedNode(step.id);
setWorkflowDiagramTriggerNodeSelection(step.id);
if (isDefined(linkOutputSchema.link.tab)) {
setActiveTabId(linkOutputSchema.link.tab);
}
};
if (isLinkOutputSchema(currentSubStep)) {
handleSelectLinkOutputSchema(currentSubStep);
} else if (isRecordOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep.fields);
} else if (isBaseOutputSchema(currentSubStep)) {
handleSelectBaseOutputSchema(currentSubStep);
}
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
const displayedObject = getDisplayedSubStepFields();
const options = displayedObject ? Object.entries(displayedObject) : [];
const filteredOptions = searchInputValue
? options.filter(
([_, value]) =>
value.label &&
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options;
return (
<>
<DropdownMenuHeader
StartIcon={IconChevronLeft}
onClick={goBack}
style={{
position: 'fixed',
}}
>
<OverflowingTextWithTooltip text={headerLabel} />
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{filteredOptions.map(([key, value]) => (
<MenuItemSelect
key={key}
selected={false}
hovered={false}
onClick={() => handleSelectField(key)}
text={value.label || key}
hasSubMenu={!value.isLeaf}
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,156 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import {
OutputSchema,
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { useState } from 'react';
import {
IconChevronLeft,
MenuItemSelect,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
type WorkflowVariablesDropdownObjectItemsProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
export const WorkflowVariablesDropdownObjectItems = ({
step,
onSelect,
onBack,
}: WorkflowVariablesDropdownObjectItemsProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const getCurrentSubStep = (): OutputSchema => {
let currentSubStep = step.outputSchema;
for (const key of currentPath) {
if (isRecordOutputSchema(currentSubStep)) {
currentSubStep = currentSubStep.fields[key]?.value;
} else if (isBaseOutputSchema(currentSubStep)) {
currentSubStep = currentSubStep[key]?.value;
}
}
return currentSubStep;
};
const getDisplayedSubStepFields = () => {
const currentSubStep = getCurrentSubStep();
if (isRecordOutputSchema(currentSubStep)) {
return currentSubStep.fields;
} else if (isBaseOutputSchema(currentSubStep)) {
return currentSubStep;
}
};
const getDisplayedSubStepObject = () => {
const currentSubStep = getCurrentSubStep();
if (!isRecordOutputSchema(currentSubStep)) {
return;
}
return currentSubStep.object;
};
const handleSelectObject = () => {
const currentSubStep = getCurrentSubStep();
if (!isRecordOutputSchema(currentSubStep)) {
return;
}
onSelect(
`{{${step.id}.${[...currentPath, currentSubStep.object.fieldIdName].join('.')}}}`,
);
};
const handleSelectField = (key: string) => {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
const displayedSubStepObject = getDisplayedSubStepObject();
const shouldDisplaySubStepObject = searchInputValue
? displayedSubStepObject?.label &&
displayedSubStepObject.label
.toLowerCase()
.includes(searchInputValue.toLowerCase())
: true;
const displayedFields = getDisplayedSubStepFields();
const options = displayedFields ? Object.entries(displayedFields) : [];
const filteredOptions = searchInputValue
? options.filter(
([_, value]) =>
value.label &&
value.label.toLowerCase().includes(searchInputValue.toLowerCase()),
)
: options;
return (
<>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
<OverflowingTextWithTooltip text={headerLabel} />
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{shouldDisplaySubStepObject && displayedSubStepObject?.label && (
<MenuItemSelect
selected={false}
hovered={false}
onClick={handleSelectObject}
text={displayedSubStepObject.label}
hasSubMenu={false}
LeftIcon={
displayedSubStepObject.icon
? getIcon(displayedSubStepObject.icon)
: undefined
}
/>
)}
{filteredOptions.map(([key, value]) => (
<MenuItemSelect
key={key}
selected={false}
hovered={false}
onClick={() => handleSelectField(key)}
text={value.label || key}
hasSubMenu={!value.isLeaf}
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1,74 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { StepOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { useState } from 'react';
import {
IconX,
MenuItem,
MenuItemSelect,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
type WorkflowVariablesDropdownWorkflowStepItemsProps = {
dropdownId: string;
steps: StepOutputSchema[];
onSelect: (value: string) => void;
};
export const WorkflowVariablesDropdownWorkflowStepItems = ({
dropdownId,
steps,
onSelect,
}: WorkflowVariablesDropdownWorkflowStepItemsProps) => {
const { getIcon } = useIcons();
const [searchInputValue, setSearchInputValue] = useState('');
const { closeDropdown } = useDropdown(dropdownId);
const availableSteps = steps.filter((step) =>
searchInputValue
? step.name.toLowerCase().includes(searchInputValue)
: true,
);
return (
<>
<DropdownMenuHeader StartIcon={IconX} onClick={closeDropdown}>
<OverflowingTextWithTooltip text={'Select Step'} />
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableSteps.length > 0 ? (
availableSteps.map((item, _index) => (
<MenuItemSelect
key={`step-${item.id}`}
selected={false}
hovered={false}
onClick={() => onSelect(item.id)}
text={item.name}
LeftIcon={item.icon ? getIcon(item.icon) : undefined}
hasSubMenu
/>
))
) : (
<MenuItem
key="no-steps"
onClick={() => {}}
text="No variables available"
LeftIcon={undefined}
hasSubMenu={false}
/>
)}
</DropdownMenuItemsContainer>
</>
);
};

View File

@ -0,0 +1 @@
export const SEARCH_VARIABLES_DROPDOWN_ID = 'workflow-variables';

View File

@ -0,0 +1,91 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import {
OutputSchema,
StepOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { filterOutputSchema } from '@/workflow/workflow-variables/utils/filterOutputSchema';
import { getTriggerStepName } from '@/workflow/workflow-variables/utils/getTriggerStepName';
import isEmpty from 'lodash.isempty';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
import { isEmptyObject } from '~/utils/isEmptyObject';
export const useAvailableVariablesInWorkflowStep = ({
objectNameSingularToSelect,
}: {
objectNameSingularToSelect?: string;
}): StepOutputSchema[] => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
return [];
}
const stepDefinition = getStepDefinitionOrThrow({
stepId: workflowSelectedNode,
workflowVersion: workflow.currentVersion,
});
if (
!isDefined(stepDefinition) ||
stepDefinition.type === 'trigger' ||
!isDefined(workflow.currentVersion.steps)
) {
return [];
}
const previousSteps = [];
for (const step of workflow.currentVersion.steps) {
if (step.id === workflowSelectedNode) {
break;
}
previousSteps.push(step);
}
const result = [];
const filteredTriggerOutputSchema = filterOutputSchema(
workflow.currentVersion.trigger?.settings?.outputSchema as
| OutputSchema
| undefined,
objectNameSingularToSelect,
);
if (
isDefined(workflow.currentVersion.trigger) &&
isDefined(filteredTriggerOutputSchema) &&
!isEmptyObject(filteredTriggerOutputSchema)
) {
result.push({
id: 'trigger',
name: isDefined(workflow.currentVersion.trigger.name)
? workflow.currentVersion.trigger.name
: getTriggerStepName(workflow.currentVersion.trigger),
outputSchema: filteredTriggerOutputSchema,
});
}
previousSteps.forEach((previousStep) => {
const filteredOutputSchema = filterOutputSchema(
previousStep.settings.outputSchema as OutputSchema,
objectNameSingularToSelect,
);
if (isDefined(filteredOutputSchema) && !isEmpty(filteredOutputSchema)) {
result.push({
id: previousStep.id,
name: previousStep.name,
outputSchema: filteredOutputSchema,
...(previousStep.type === 'CODE' ? { icon: 'IconCode' } : {}),
});
}
});
return result;
};

View File

@ -0,0 +1,48 @@
import { InputSchemaPropertyType } from '@/workflow/types/InputSchema';
type Leaf = {
isLeaf: true;
type?: InputSchemaPropertyType;
icon?: string;
label?: string;
value: any;
};
type Node = {
isLeaf: false;
icon?: string;
label?: string;
value: OutputSchema;
};
type Link = {
isLeaf: true;
tab?: string;
icon?: string;
label?: string;
};
export type BaseOutputSchema = Record<string, Leaf | Node>;
export type RecordOutputSchema = {
object: { nameSingular: string; fieldIdName: string } & Leaf;
fields: BaseOutputSchema;
_outputSchemaType: 'RECORD';
};
export type LinkOutputSchema = {
link: Link;
_outputSchemaType: 'LINK';
};
export type OutputSchema =
| BaseOutputSchema
| RecordOutputSchema
| LinkOutputSchema;
export type StepOutputSchema = {
id: string;
name: string;
icon?: string;
outputSchema: OutputSchema;
};

View File

@ -0,0 +1,13 @@
import { extractVariableLabel } from '../extractVariableLabel';
it('returns the last part of a properly formatted variable', () => {
const rawVariable = '{{a.b.c}}';
expect(extractVariableLabel(rawVariable)).toBe('c');
});
it('stops on unclosed variables', () => {
const rawVariable = '{{ test {{a.b.c}}';
expect(extractVariableLabel(rawVariable)).toBe('c');
});

View File

@ -0,0 +1,185 @@
import { OutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import { filterOutputSchema } from '../filterOutputSchema';
describe('filterOutputSchema', () => {
describe('edge cases', () => {
it('should return the input schema when objectNameSingularToSelect is undefined', () => {
const inputSchema: OutputSchema = {
_outputSchemaType: 'RECORD',
object: {
nameSingular: 'person',
fieldIdName: 'id',
isLeaf: true,
value: 'Fake value',
},
fields: {},
};
expect(filterOutputSchema(inputSchema, undefined)).toBe(inputSchema);
});
it('should return undefined when input schema is undefined', () => {
expect(filterOutputSchema(undefined, 'person')).toBeUndefined();
});
});
describe('record output schema', () => {
const createRecordSchema = (
nameSingular: string,
fields = {},
): OutputSchema => ({
_outputSchemaType: 'RECORD',
object: {
nameSingular,
fieldIdName: 'id',
isLeaf: true,
value: 'Fake value',
},
fields,
});
it('should keep a matching record schema', () => {
const inputSchema = createRecordSchema('person');
expect(filterOutputSchema(inputSchema, 'person')).toEqual(inputSchema);
});
it('should filter out a non-matching record schema with no valid fields', () => {
const inputSchema = createRecordSchema('company');
expect(filterOutputSchema(inputSchema, 'person')).toBeUndefined();
});
it('should keep valid nested records while filtering out invalid ones', () => {
const inputSchema = createRecordSchema('company', {
employee: {
isLeaf: false,
value: createRecordSchema('person', {
manager: {
isLeaf: false,
value: createRecordSchema('person'),
},
}),
},
department: {
isLeaf: false,
value: createRecordSchema('department'),
},
});
const expectedSchema = {
_outputSchemaType: 'RECORD',
fields: {
employee: {
isLeaf: false,
value: createRecordSchema('person', {
manager: {
isLeaf: false,
value: createRecordSchema('person'),
},
}),
},
},
};
expect(filterOutputSchema(inputSchema, 'person')).toEqual(expectedSchema);
});
it('should ignore leaf fields', () => {
const inputSchema = createRecordSchema('company', {
name: { isLeaf: true, value: 'string' },
employee: {
isLeaf: false,
value: createRecordSchema('person'),
},
});
const expectedSchema = {
_outputSchemaType: 'RECORD',
fields: {
employee: {
isLeaf: false,
value: createRecordSchema('person'),
},
},
};
expect(filterOutputSchema(inputSchema, 'person')).toEqual(expectedSchema);
});
});
describe('base output schema', () => {
const createBaseSchema = (fields = {}): OutputSchema => ({
...fields,
});
it('should filter out base schema with no valid records', () => {
const inputSchema = createBaseSchema({
field1: {
isLeaf: true,
value: 'string',
},
});
expect(filterOutputSchema(inputSchema, 'person')).toBeUndefined();
});
it('should keep base schema with valid nested records', () => {
const inputSchema = createBaseSchema({
field1: {
isLeaf: false,
value: {
_outputSchemaType: 'RECORD',
object: { nameSingular: 'person' },
fields: {},
},
},
});
expect(filterOutputSchema(inputSchema, 'person')).toEqual({
field1: {
isLeaf: false,
value: {
_outputSchemaType: 'RECORD',
object: { nameSingular: 'person' },
fields: {},
},
},
});
});
it('should handle deeply nested valid records', () => {
const inputSchema = createBaseSchema({
level1: {
isLeaf: false,
value: createBaseSchema({
level2: {
isLeaf: false,
value: {
_outputSchemaType: 'RECORD',
object: { nameSingular: 'person' },
fields: {},
},
},
}),
},
});
expect(filterOutputSchema(inputSchema, 'person')).toEqual({
level1: {
isLeaf: false,
value: {
level2: {
isLeaf: false,
value: {
_outputSchemaType: 'RECORD',
object: { nameSingular: 'person' },
fields: {},
},
},
},
},
});
});
});
});

View File

@ -0,0 +1,53 @@
import { getTriggerStepName } from '../getTriggerStepName';
it('returns the expected name for a DATABASE_EVENT trigger', () => {
expect(
getTriggerStepName({
type: 'DATABASE_EVENT',
name: '',
settings: {
eventName: 'company.created',
outputSchema: {},
},
}),
).toBe('Company is Created');
});
it('returns the expected name for a MANUAL trigger without a defined objectType', () => {
expect(
getTriggerStepName({
type: 'MANUAL',
name: '',
settings: {
objectType: undefined,
outputSchema: {},
},
}),
).toBe('Manual trigger');
});
it('returns the expected name for a MANUAL trigger with a defined objectType', () => {
expect(
getTriggerStepName({
type: 'MANUAL',
name: '',
settings: {
objectType: 'company',
outputSchema: {},
},
}),
).toBe('Manual trigger for Company');
});
it('throws when an unknown trigger type is provided', () => {
expect(() => {
getTriggerStepName({
type: 'unknown' as any,
name: '',
settings: {
objectType: 'company',
outputSchema: {},
},
});
}).toThrow();
});

View File

@ -0,0 +1,195 @@
import { Editor } from '@tiptap/react';
import { initializeEditorContent } from '../initializeEditorContent';
describe('initializeEditorContent', () => {
let mockEditor: Editor;
beforeEach(() => {
mockEditor = {
commands: {
insertContent: jest.fn(),
},
} as unknown as Editor;
});
it('should handle single line text', () => {
initializeEditorContent(mockEditor, 'Hello world');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith(
'Hello world',
);
});
it('should handle text with newlines', () => {
initializeEditorContent(mockEditor, 'Line 1\nLine 2');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Line 1',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
'Line 2',
);
});
it('should handle single variable', () => {
initializeEditorContent(mockEditor, '{{user.name}}');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(1);
expect(mockEditor.commands.insertContent).toHaveBeenCalledWith({
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
});
it('should handle text with variables', () => {
initializeEditorContent(mockEditor, 'Hello {{user.name}}, welcome!');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
', welcome!',
);
});
it('should handle text with multiple variables', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.firstName}} {{user.lastName}}!',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.firstName}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, ' ');
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(4, {
type: 'variableTag',
attrs: { variable: '{{user.lastName}}' },
});
});
it('should handle newlines with variables', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.name}}\nWelcome to {{app.name}}',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(5);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
4,
'Welcome to ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(5, {
type: 'variableTag',
attrs: { variable: '{{app.name}}' },
});
});
it('should handle empty strings', () => {
initializeEditorContent(mockEditor, '');
expect(mockEditor.commands.insertContent).not.toHaveBeenCalled();
});
it('should handle multiple empty parts', () => {
initializeEditorContent(mockEditor, 'Hello {{user.name}} !');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
' !',
);
});
it('should handle multiple newlines', () => {
initializeEditorContent(mockEditor, 'Line1\n\nLine3');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(4);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Line1',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(3, {
type: 'hardBreak',
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
4,
'Line3',
);
});
it('should ignore malformed variable tags', () => {
initializeEditorContent(
mockEditor,
'Hello {{user.name}} and {{invalid}more}} text',
);
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(3);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello ',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'variableTag',
attrs: { variable: '{{user.name}}' },
});
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
3,
' and {{invalid}more}} text',
);
});
it('should handle trailing newlines', () => {
initializeEditorContent(mockEditor, 'Hello\n');
expect(mockEditor.commands.insertContent).toHaveBeenCalledTimes(2);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(
1,
'Hello',
);
expect(mockEditor.commands.insertContent).toHaveBeenNthCalledWith(2, {
type: 'hardBreak',
});
});
});

View File

@ -0,0 +1,299 @@
import { JSONContent } from '@tiptap/react';
import { parseEditorContent } from '../parseEditorContent';
describe('parseEditorContent', () => {
it('should parse empty doc', () => {
const input: JSONContent = {
type: 'doc',
content: [],
};
expect(parseEditorContent(input)).toBe('');
});
it('should parse simple text node', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello world',
},
],
},
],
};
expect(parseEditorContent(input)).toBe('Hello world');
});
it('should parse variable tag node', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'variableTag',
attrs: {
variable: '{{user.name}}',
},
},
],
},
],
};
expect(parseEditorContent(input)).toBe('{{user.name}}');
});
it('should parse mixed content with text and variables', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello ',
},
{
type: 'variableTag',
attrs: {
variable: '{{user.name}}',
},
},
{
type: 'text',
text: ', welcome to ',
},
{
type: 'variableTag',
attrs: {
variable: '{{app.name}}',
},
},
],
},
],
};
expect(parseEditorContent(input)).toBe(
'Hello {{user.name}}, welcome to {{app.name}}',
);
});
it('should parse multiple paragraphs', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'First line',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Second line',
},
],
},
],
};
expect(parseEditorContent(input)).toBe('First lineSecond line');
});
it('should handle missing content array', () => {
const input: JSONContent = {
type: 'doc',
};
expect(parseEditorContent(input)).toBe('');
});
it('should handle missing text in text node', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
},
],
},
],
};
expect(parseEditorContent(input)).toBe('');
});
it('should handle missing variable in variableTag node', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'variableTag',
attrs: {},
},
],
},
],
};
expect(parseEditorContent(input)).toBe('');
});
it('should handle unknown node types', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'unknownType',
content: [
{
type: 'text',
text: 'This should be ignored',
},
],
},
],
},
],
};
expect(parseEditorContent(input)).toBe('');
});
it('should parse complex nested structure', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello ',
},
{
type: 'variableTag',
attrs: {
variable: '{{user.firstName}}',
},
},
{
type: 'text',
text: ' ',
},
{
type: 'variableTag',
attrs: {
variable: '{{user.lastName}}',
},
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Your ID is: ',
},
{
type: 'variableTag',
attrs: {
variable: '{{user.id}}',
},
},
],
},
],
};
expect(parseEditorContent(input)).toBe(
'Hello {{user.firstName}} {{user.lastName}}Your ID is: {{user.id}}',
);
});
it('should handle hard breaks', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'First line',
},
],
},
{
type: 'hardBreak',
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Second line',
},
],
},
],
};
expect(parseEditorContent(input)).toBe('First line\nSecond line');
});
it('should handle spaces between variables correctly', () => {
const input: JSONContent = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'variableTag',
attrs: { variable: '{{user.firstName}}' },
},
{
type: 'text',
text: '\u00A0', // NBSP character
},
{
type: 'variableTag',
attrs: { variable: '{{user.lastName}}' },
},
],
},
],
};
expect(parseEditorContent(input)).toBe(
'{{user.firstName}} {{user.lastName}}',
);
});
});

View File

@ -0,0 +1,21 @@
import { isDefined } from 'twenty-ui';
const CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX = /{{([^{}]+)}}/g;
export const extractVariableLabel = (rawVariableName: string) => {
const variableWithoutBrackets = rawVariableName.replace(
CAPTURE_ALL_VARIABLE_TAG_INNER_REGEX,
(_, variableName) => {
return variableName;
},
);
const parts = variableWithoutBrackets.split('.');
const displayText = parts.at(-1);
if (!isDefined(displayText)) {
throw new Error('Expected to find at least one splitted chunk.');
}
return displayText;
};

View File

@ -0,0 +1,118 @@
import {
BaseOutputSchema,
OutputSchema,
RecordOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/workflow-variables/utils/isBaseOutputSchema';
import { isLinkOutputSchema } from '@/workflow/workflow-variables/utils/isLinkOutputSchema';
import { isRecordOutputSchema } from '@/workflow/workflow-variables/utils/isRecordOutputSchema';
import { isDefined } from 'twenty-ui';
const isValidRecordOutputSchema = (
outputSchema: RecordOutputSchema,
objectNameSingularToSelect?: string,
): boolean => {
if (isDefined(objectNameSingularToSelect)) {
return (
isDefined(outputSchema.object) &&
outputSchema.object.nameSingular === objectNameSingularToSelect
);
}
return true;
};
const filterRecordOutputSchema = (
outputSchema: RecordOutputSchema,
objectNameSingularToSelect: string,
): RecordOutputSchema | undefined => {
const filteredFields: BaseOutputSchema = {};
let hasValidFields = false;
for (const key in outputSchema.fields) {
const field = outputSchema.fields[key];
if (field.isLeaf) {
continue;
}
const validSubSchema = filterOutputSchema(
field.value,
objectNameSingularToSelect,
);
if (isDefined(validSubSchema)) {
filteredFields[key] = {
...field,
value: validSubSchema,
};
hasValidFields = true;
}
}
if (isValidRecordOutputSchema(outputSchema, objectNameSingularToSelect)) {
return {
...outputSchema,
fields: filteredFields,
};
} else if (hasValidFields) {
return {
_outputSchemaType: 'RECORD',
fields: filteredFields,
} as RecordOutputSchema;
}
return undefined;
};
const filterBaseOutputSchema = (
outputSchema: BaseOutputSchema,
objectNameSingularToSelect: string,
): BaseOutputSchema | undefined => {
const filteredSchema: BaseOutputSchema = {};
let hasValidFields = false;
for (const key in outputSchema) {
const field = outputSchema[key];
if (field.isLeaf) {
continue;
}
const validSubSchema = filterOutputSchema(
field.value,
objectNameSingularToSelect,
);
if (isDefined(validSubSchema)) {
filteredSchema[key] = {
...field,
value: validSubSchema,
};
hasValidFields = true;
}
}
if (hasValidFields) {
return filteredSchema;
}
return undefined;
};
export const filterOutputSchema = (
outputSchema?: OutputSchema,
objectNameSingularToSelect?: string,
): OutputSchema | undefined => {
if (!objectNameSingularToSelect || !outputSchema) {
return outputSchema;
}
if (isLinkOutputSchema(outputSchema)) {
return outputSchema;
} else if (isRecordOutputSchema(outputSchema)) {
return filterRecordOutputSchema(outputSchema, objectNameSingularToSelect);
} else if (isBaseOutputSchema(outputSchema)) {
return filterBaseOutputSchema(outputSchema, objectNameSingularToSelect);
}
return undefined;
};

View File

@ -0,0 +1,30 @@
import {
WorkflowDatabaseEventTrigger,
WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const getTriggerStepName = (trigger: WorkflowTrigger): string => {
switch (trigger.type) {
case 'DATABASE_EVENT':
return getDatabaseEventTriggerStepName(trigger);
case 'MANUAL':
if (!isDefined(trigger.settings.objectType)) {
return 'Manual trigger';
}
return 'Manual trigger for ' + capitalize(trigger.settings.objectType);
}
return assertUnreachable(trigger);
};
const getDatabaseEventTriggerStepName = (
trigger: WorkflowDatabaseEventTrigger,
): string => {
const [object, action] = trigger.settings.eventName.split('.');
return `${capitalize(object)} is ${capitalize(action)}`;
};

View File

@ -0,0 +1,36 @@
import { isNonEmptyString } from '@sniptt/guards';
import { Editor } from '@tiptap/react';
export const CAPTURE_VARIABLE_TAG_REGEX = /({{[^{}]+}})/;
export const initializeEditorContent = (editor: Editor, content: string) => {
const lines = content.split(/\n/);
lines.forEach((line, index) => {
const parts = line.split(CAPTURE_VARIABLE_TAG_REGEX);
parts.forEach((part) => {
if (part.length === 0) {
return;
}
if (part.startsWith('{{') && part.endsWith('}}')) {
editor.commands.insertContent({
type: 'variableTag',
attrs: { variable: part },
});
return;
}
if (isNonEmptyString(part)) {
editor.commands.insertContent(part);
}
});
// Add hard break if it's not the last line
if (index < lines.length - 1) {
editor.commands.insertContent({
type: 'hardBreak',
});
}
});
};

View File

@ -0,0 +1,10 @@
import {
BaseOutputSchema,
OutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
export const isBaseOutputSchema = (
outputSchema: OutputSchema,
): outputSchema is BaseOutputSchema => {
return !outputSchema._outputSchemaType;
};

View File

@ -0,0 +1,10 @@
import {
LinkOutputSchema,
OutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
export const isLinkOutputSchema = (
outputSchema: OutputSchema,
): outputSchema is LinkOutputSchema => {
return outputSchema._outputSchemaType === 'LINK';
};

View File

@ -0,0 +1,10 @@
import {
OutputSchema,
RecordOutputSchema,
} from '@/workflow/workflow-variables/types/StepOutputSchema';
export const isRecordOutputSchema = (
outputSchema: OutputSchema,
): outputSchema is RecordOutputSchema => {
return outputSchema._outputSchemaType === 'RECORD';
};

View File

@ -0,0 +1,30 @@
import { JSONContent } from '@tiptap/react';
import { isDefined } from 'twenty-ui';
export const parseEditorContent = (json: JSONContent): string => {
const parseNode = (node: JSONContent): string => {
if (
(node.type === 'paragraph' || node.type === 'doc') &&
isDefined(node.content)
) {
return node.content.map(parseNode).join('');
}
if (node.type === 'text') {
// Replace &nbsp; with regular space
return node?.text?.replace(/\u00A0/g, ' ') ?? '';
}
if (node.type === 'hardBreak') {
return '\n';
}
if (node.type === 'variableTag') {
return node.attrs?.variable || '';
}
return '';
};
return parseNode(json);
};

View File

@ -0,0 +1,60 @@
import { extractVariableLabel } from '@/workflow/workflow-variables/utils/extractVariableLabel';
import { Node } from '@tiptap/core';
import { mergeAttributes } from '@tiptap/react';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
variableTag: {
insertVariableTag: (variableName: string) => ReturnType;
};
}
}
export const VariableTag = Node.create({
name: 'variableTag',
group: 'inline',
inline: true,
atom: true,
addAttributes: () => ({
variable: {
default: null,
parseHTML: (element) => element.getAttribute('data-variable'),
renderHTML: (attributes) => {
return {
'data-variable': attributes.variable,
};
},
},
}),
renderHTML: ({ node, HTMLAttributes }) => {
const variable = node.attrs.variable as string;
return [
'span',
mergeAttributes(HTMLAttributes, {
'data-type': 'variableTag',
class: 'variable-tag',
}),
extractVariableLabel(variable),
];
},
renderText: ({ node }) => {
return node.attrs.variable;
},
addCommands: () => ({
insertVariableTag:
(variableName: string) =>
({ commands }) => {
commands.insertContent({
type: 'variableTag',
attrs: { variable: variableName },
});
return true;
},
}),
});