Select full record in variable dropdown (#8851)

Output schema is now separated in two sections:
- object, that gather all informations on the selectable object
- fields, that display object fields in a record context, or simply the
available fields from the previous steps

The dropdown variable has now a new mode:
- if objectNameSingularToSelect is defined, it goes into an object mode.
Only objects of the right type will be shown
- if not set, it will use the already existing mode, to select a field

When an object is selected, it actually set the id of the object



https://github.com/user-attachments/assets/1c95f8fd-10f0-4c1c-aeb7-c7d847e89536
This commit is contained in:
Thomas Trompette 2024-12-05 10:48:34 +01:00 committed by GitHub
parent 33e69805cb
commit 36e4357bb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 934 additions and 268 deletions

View File

@ -1,3 +1,4 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { RecordChip } from '@/object-record/components/RecordChip';
import { VariableChip } from '@/object-record/record-field/form-types/components/VariableChip';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
@ -39,13 +40,18 @@ export const WorkflowSingleRecordFieldChip = ({
objectNameSingular,
onRemove,
}: WorkflowSingleRecordFieldChipProps) => {
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
if (
!!draftValue &&
draftValue.type === 'variable' &&
isStandaloneVariableString(draftValue.value)
) {
return (
<VariableChip rawVariableName={draftValue.value} onRemove={onRemove} />
<VariableChip
rawVariableName={objectMetadataItem.labelSingular}
onRemove={onRemove}
/>
);
}

View File

@ -12,7 +12,6 @@ import { SingleRecordSelect } from '@/object-record/relation-picker/components/S
import { useRecordPicker } from '@/object-record/relation-picker/hooks/useRecordPicker';
import { RecordPickerComponentInstanceContext } from '@/object-record/relation-picker/states/contexts/RecordPickerComponentInstanceContext';
import { RecordForSelect } from '@/object-record/relation-picker/types/RecordForSelect';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
@ -22,7 +21,7 @@ import SearchVariablesDropdown from '@/workflow/search-variables/components/Sear
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { isValidUuid } from '~/utils/isValidUuid';
const StyledFormSelectContainer = styled.div`
@ -62,6 +61,16 @@ const StyledSearchVariablesDropdownContainer = styled.div`
export type RecordId = string;
export type Variable = string;
type WorkflowSingleRecordPickerValue =
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
};
export type WorkflowSingleRecordPickerProps = {
label?: string;
defaultValue: RecordId | Variable;
@ -75,16 +84,7 @@ export const WorkflowSingleRecordPicker = ({
objectNameSingular,
onChange,
}: WorkflowSingleRecordPickerProps) => {
const [draftValue, setDraftValue] = useState<
| {
type: 'static';
value: RecordId;
}
| {
type: 'variable';
value: Variable;
}
>(
const draftValue: WorkflowSingleRecordPickerValue =
isStandaloneVariableString(defaultValue)
? {
type: 'variable',
@ -93,10 +93,9 @@ export const WorkflowSingleRecordPicker = ({
: {
type: 'static',
value: defaultValue || '',
},
);
};
const { record } = useFindOneRecord({
const { record: selectedRecord } = useFindOneRecord({
objectRecordId:
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
@ -106,10 +105,6 @@ export const WorkflowSingleRecordPicker = ({
skip: !isValidUuid(defaultValue),
});
const [selectedRecord, setSelectedRecord] = useState<
ObjectRecord | undefined
>(record);
const dropdownId = `workflow-record-picker-${objectNameSingular}`;
const variablesDropdownId = `workflow-record-picker-${objectNameSingular}-variables`;
@ -126,32 +121,16 @@ export const WorkflowSingleRecordPicker = ({
const handleRecordSelected = (
selectedEntity: RecordForSelect | null | undefined,
) => {
setDraftValue({
type: 'static',
value: selectedEntity?.record?.id ?? '',
});
setSelectedRecord(selectedEntity?.record);
closeDropdown();
onChange?.(selectedEntity?.record?.id ?? '');
closeDropdown();
};
const handleVariableTagInsert = (variable: string) => {
setDraftValue({
type: 'variable',
value: variable,
});
setSelectedRecord(undefined);
closeDropdown();
onChange?.(variable);
closeDropdown();
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
value: '',
});
closeDropdown();
onChange('');
@ -211,6 +190,7 @@ export const WorkflowSingleRecordPicker = ({
inputId={variablesDropdownId}
onVariableSelect={handleVariableTagInsert}
disabled={false}
objectNameSingularToSelect={objectNameSingular}
/>
</StyledSearchVariablesDropdownContainer>
</StyledFormFieldInputRowContainer>

View File

@ -1,16 +1,16 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SearchVariablesDropdownStepItem } from '@/workflow/search-variables/components/SearchVariablesDropdownStepItem';
import SearchVariablesDropdownStepSubItem from '@/workflow/search-variables/components/SearchVariablesDropdownStepSubItem';
import { SearchVariablesDropdownFieldItems } from '@/workflow/search-variables/components/SearchVariablesDropdownFieldItems';
import { SearchVariablesDropdownObjectItems } from '@/workflow/search-variables/components/SearchVariablesDropdownObjectItems';
import { SearchVariablesDropdownWorkflowStepItems } from '@/workflow/search-variables/components/SearchVariablesDropdownWorkflowStepItems';
import { SEARCH_VARIABLES_DROPDOWN_ID } from '@/workflow/search-variables/constants/SearchVariablesDropdownId';
import { useAvailableVariablesInWorkflowStep } from '@/workflow/search-variables/hooks/useAvailableVariablesInWorkflowStep';
import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconVariablePlus } from 'twenty-ui';
import { IconVariablePlus, isDefined } from 'twenty-ui';
const StyledDropdownVariableButtonContainer = styled(
StyledDropdownButtonContainer,
@ -26,21 +26,28 @@ const StyledDropdownVariableButtonContainer = styled(
}
`;
const StyledDropdownComponetsContainer = styled.div`
background-color: ${({ theme }) => theme.background.transparent.light};
`;
const SearchVariablesDropdown = ({
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 } = useDropdown(dropdownId);
const availableVariablesInWorkflowStep =
useAvailableVariablesInWorkflowStep();
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
const availableVariablesInWorkflowStep = useAvailableVariablesInWorkflowStep({
objectNameSingularToSelect,
});
const initialStep =
availableVariablesInWorkflowStep.length === 1
@ -59,12 +66,44 @@ const SearchVariablesDropdown = ({
const handleSubItemSelect = (subItem: string) => {
onVariableSelect(subItem);
setSelectedStep(undefined);
closeDropdown();
};
const handleBack = () => {
setSelectedStep(undefined);
};
const renderSearchVariablesDropdownComponents = () => {
if (!isDefined(selectedStep)) {
return (
<SearchVariablesDropdownWorkflowStepItems
dropdownId={dropdownId}
steps={availableVariablesInWorkflowStep}
onSelect={handleStepSelect}
/>
);
}
if (isDefined(objectNameSingularToSelect)) {
return (
<SearchVariablesDropdownObjectItems
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
);
}
return (
<SearchVariablesDropdownFieldItems
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
);
};
if (disabled === true) {
return (
<StyledDropdownVariableButtonContainer
@ -97,20 +136,9 @@ const SearchVariablesDropdown = ({
</StyledDropdownVariableButtonContainer>
}
dropdownComponents={
<DropdownMenuItemsContainer hasMaxHeight>
{selectedStep ? (
<SearchVariablesDropdownStepSubItem
step={selectedStep}
onSelect={handleSubItemSelect}
onBack={handleBack}
/>
) : (
<SearchVariablesDropdownStepItem
steps={availableVariablesInWorkflowStep}
onSelect={handleStepSelect}
/>
)}
</DropdownMenuItemsContainer>
<StyledDropdownComponetsContainer>
{renderSearchVariablesDropdownComponents()}
</StyledDropdownComponetsContainer>
}
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 4 }}

View File

@ -0,0 +1,133 @@
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 {
BaseOutputSchema,
OutputSchema,
StepOutputSchema,
} from '@/workflow/search-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/search-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/search-variables/utils/isRecordOutputSchema';
import { useTheme } from '@emotion/react';
import { useState } from 'react';
import {
HorizontalSeparator,
IconChevronLeft,
MenuItemSelect,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
type SearchVariablesDropdownFieldItemsProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
export const SearchVariablesDropdownFieldItems = ({
step,
onSelect,
onBack,
}: SearchVariablesDropdownFieldItemsProps) => {
const theme = useTheme();
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 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('.')}}}`);
}
};
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 (
<DropdownMenuItemsContainer>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
<OverflowingTextWithTooltip text={headerLabel} />
</DropdownMenuHeader>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
{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,159 @@
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 {
OutputSchema,
StepOutputSchema,
} from '@/workflow/search-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/search-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/search-variables/utils/isRecordOutputSchema';
import { useTheme } from '@emotion/react';
import { useState } from 'react';
import {
HorizontalSeparator,
IconChevronLeft,
MenuItemSelect,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui';
type SearchVariablesDropdownObjectItemsProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
export const SearchVariablesDropdownObjectItems = ({
step,
onSelect,
onBack,
}: SearchVariablesDropdownObjectItemsProps) => {
const theme = useTheme();
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();
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 (
<DropdownMenuItemsContainer>
<DropdownMenuHeader StartIcon={IconChevronLeft} onClick={goBack}>
<OverflowingTextWithTooltip text={headerLabel} />
</DropdownMenuHeader>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
{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

@ -1,36 +0,0 @@
import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import { MenuItem, MenuItemSelect } from 'twenty-ui';
type SearchVariablesDropdownStepItemProps = {
steps: StepOutputSchema[];
onSelect: (value: string) => void;
};
export const SearchVariablesDropdownStepItem = ({
steps,
onSelect,
}: SearchVariablesDropdownStepItemProps) => {
return steps.length > 0 ? (
<>
{steps.map((item, _index) => (
<MenuItemSelect
key={`step-${item.id}`}
selected={false}
hovered={false}
onClick={() => onSelect(item.id)}
text={item.name}
LeftIcon={undefined}
hasSubMenu
/>
))}
</>
) : (
<MenuItem
key="no-steps"
onClick={() => {}}
text="No variables available"
LeftIcon={undefined}
hasSubMenu={false}
/>
);
};

View File

@ -1,92 +0,0 @@
import {
OverflowingTextWithTooltip,
IconChevronLeft,
MenuItemSelect,
useIcons,
} from 'twenty-ui';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader';
import {
OutputSchema,
StepOutputSchema,
} from '@/workflow/search-variables/types/StepOutputSchema';
import { useState } from 'react';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
type SearchVariablesDropdownStepSubItemProps = {
step: StepOutputSchema;
onSelect: (value: string) => void;
onBack: () => void;
};
const SearchVariablesDropdownStepSubItem = ({
step,
onSelect,
onBack,
}: SearchVariablesDropdownStepSubItemProps) => {
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [searchInputValue, setSearchInputValue] = useState('');
const { getIcon } = useIcons();
const getSelectedObject = (): OutputSchema => {
let selected = step.outputSchema;
for (const key of currentPath) {
selected = selected[key]?.value;
}
return selected;
};
const handleSelect = (key: string) => {
const selectedObject = getSelectedObject();
if (!selectedObject[key]?.isLeaf) {
setCurrentPath([...currentPath, key]);
setSearchInputValue('');
} else {
onSelect(`{{${step.id}.${[...currentPath, key].join('.')}}}`);
}
};
const goBack = () => {
if (currentPath.length === 0) {
onBack();
} else {
setCurrentPath(currentPath.slice(0, -1));
}
};
const headerLabel = currentPath.length === 0 ? step.name : currentPath.at(-1);
const options = Object.entries(getSelectedObject());
const filteredOptions = searchInputValue
? options.filter(([key]) =>
key.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)}
/>
{filteredOptions.map(([key, value]) => (
<MenuItemSelect
key={key}
selected={false}
hovered={false}
onClick={() => handleSelect(key)}
text={key}
hasSubMenu={!value.isLeaf}
LeftIcon={value.icon ? getIcon(value.icon) : undefined}
/>
))}
</>
);
};
export default SearchVariablesDropdownStepSubItem;

View File

@ -0,0 +1,79 @@
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 { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import { useTheme } from '@emotion/react';
import { useState } from 'react';
import {
HorizontalSeparator,
IconX,
MenuItem,
MenuItemSelect,
OverflowingTextWithTooltip,
} from 'twenty-ui';
type SearchVariablesDropdownWorkflowStepItemsProps = {
dropdownId: string;
steps: StepOutputSchema[];
onSelect: (value: string) => void;
};
export const SearchVariablesDropdownWorkflowStepItems = ({
dropdownId,
steps,
onSelect,
}: SearchVariablesDropdownWorkflowStepItemsProps) => {
const theme = useTheme();
const [searchInputValue, setSearchInputValue] = useState('');
const { closeDropdown } = useDropdown(dropdownId);
const availableSteps = steps.filter((step) =>
searchInputValue
? step.name.toLowerCase().includes(searchInputValue)
: true,
);
return (
<DropdownMenuItemsContainer>
<DropdownMenuHeader StartIcon={IconX} onClick={closeDropdown}>
<OverflowingTextWithTooltip text={'Select Step'} />
</DropdownMenuHeader>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={(event) => setSearchInputValue(event.target.value)}
/>
<HorizontalSeparator
color={theme.background.transparent.primary}
noMargin
/>
{availableSteps.length > 0 ? (
availableSteps.map((item, _index) => (
<MenuItemSelect
key={`step-${item.id}`}
selected={false}
hovered={false}
onClick={() => onSelect(item.id)}
text={item.name}
LeftIcon={undefined}
hasSubMenu
/>
))
) : (
<MenuItem
key="no-steps"
onClick={() => {}}
text="No variables available"
LeftIcon={undefined}
hasSubMenu={false}
/>
)}
</DropdownMenuItemsContainer>
);
};

View File

@ -1,5 +1,9 @@
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { StepOutputSchema } from '@/workflow/search-variables/types/StepOutputSchema';
import {
OutputSchema,
StepOutputSchema,
} from '@/workflow/search-variables/types/StepOutputSchema';
import { filterOutputSchema } from '@/workflow/search-variables/utils/filterOutputSchema';
import { getTriggerStepName } from '@/workflow/search-variables/utils/getTriggerStepName';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
@ -9,7 +13,11 @@ import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
import { isEmptyObject } from '~/utils/isEmptyObject';
export const useAvailableVariablesInWorkflowStep = (): StepOutputSchema[] => {
export const useAvailableVariablesInWorkflowStep = ({
objectNameSingularToSelect,
}: {
objectNameSingularToSelect?: string;
}): StepOutputSchema[] => {
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
@ -42,29 +50,38 @@ export const useAvailableVariablesInWorkflowStep = (): StepOutputSchema[] => {
const result = [];
const filteredTriggerOutputSchema = filterOutputSchema(
workflow.currentVersion.trigger?.settings?.outputSchema as
| OutputSchema
| undefined,
objectNameSingularToSelect,
);
if (
isDefined(workflow.currentVersion.trigger) &&
isDefined(workflow.currentVersion.trigger?.settings?.outputSchema) &&
!isEmptyObject(workflow.currentVersion.trigger?.settings?.outputSchema)
isDefined(filteredTriggerOutputSchema) &&
!isEmptyObject(filteredTriggerOutputSchema)
) {
result.push({
id: 'trigger',
name: isDefined(workflow.currentVersion.trigger.name)
? workflow.currentVersion.trigger.name
: getTriggerStepName(workflow.currentVersion.trigger),
outputSchema: workflow.currentVersion.trigger.settings.outputSchema,
outputSchema: filteredTriggerOutputSchema,
});
}
previousSteps.forEach((previousStep) => {
if (
isDefined(previousStep.settings.outputSchema) &&
!isEmpty(previousStep.settings.outputSchema)
) {
const filteredOutputSchema = filterOutputSchema(
previousStep.settings.outputSchema as OutputSchema,
objectNameSingularToSelect,
);
if (isDefined(filteredOutputSchema) && !isEmpty(filteredOutputSchema)) {
result.push({
id: previousStep.id,
name: previousStep.name,
outputSchema: previousStep.settings.outputSchema,
outputSchema: filteredOutputSchema,
});
}
});

View File

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

View File

@ -0,0 +1,185 @@
import { OutputSchema } from '@/workflow/search-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,115 @@
import {
BaseOutputSchema,
OutputSchema,
RecordOutputSchema,
} from '@/workflow/search-variables/types/StepOutputSchema';
import { isBaseOutputSchema } from '@/workflow/search-variables/utils/isBaseOutputSchema';
import { isRecordOutputSchema } from '@/workflow/search-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 (isRecordOutputSchema(outputSchema)) {
return filterRecordOutputSchema(outputSchema, objectNameSingularToSelect);
} else if (isBaseOutputSchema(outputSchema)) {
return filterBaseOutputSchema(outputSchema, objectNameSingularToSelect);
}
return undefined;
};

View File

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

View File

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

View File

@ -1,9 +1,12 @@
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
type FakeValueTypes =
| string
| number
| boolean
| Date
| FakeValueTypes[]
| FieldMetadataType
| { [key: string]: FakeValueTypes };
export const generateFakeValue = (valueType: string): FakeValueTypes => {
@ -35,6 +38,8 @@ export const generateFakeValue = (valueType: string): FakeValueTypes => {
});
return objData;
} else if (valueType === FieldMetadataType.TEXT) {
return 'My text';
} else {
return 'generated-string-value';
}

View File

@ -6,25 +6,25 @@ import { join } from 'path';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkflowBuilderWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-builder.workspace-service';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { INDEX_FILE_NAME } from 'src/engine/core-modules/serverless/drivers/constants/index-file-name';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowBuilderWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-builder.workspace-service';
import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { isDefined } from 'src/utils/is-defined';
const TRIGGER_STEP_ID = 'trigger';

View File

@ -1,16 +1,26 @@
import { InputSchemaPropertyType } from 'src/modules/code-introspection/types/input-schema.type';
type Leaf = {
export type Leaf = {
isLeaf: true;
icon?: string;
type?: InputSchemaPropertyType;
label?: string;
value: any;
};
type Node = {
export type Node = {
isLeaf: false;
icon?: string;
label?: string;
value: OutputSchema;
};
export type OutputSchema = Record<string, Leaf | Node>;
export type BaseOutputSchema = Record<string, Leaf | Node>;
export type RecordOutputSchema = {
object: { nameSingular: string; fieldIdName: string } & Leaf;
fields: BaseOutputSchema;
_outputSchemaType: 'RECORD';
};
export type OutputSchema = BaseOutputSchema | RecordOutputSchema;

View File

@ -2,13 +2,13 @@ import { v4 } from 'uuid';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { BaseOutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
export const generateFakeObjectRecordEvent = (
objectMetadataEntity: ObjectMetadataEntity,
action: DatabaseEventAction,
): OutputSchema => {
): BaseOutputSchema => {
const recordId = v4();
const userId = v4();
const workspaceMemberId = v4();
@ -16,23 +16,30 @@ export const generateFakeObjectRecordEvent = (
const after = generateFakeObjectRecord(objectMetadataEntity);
const formattedObjectMetadataEntity = Object.entries(
objectMetadataEntity,
).reduce((acc: OutputSchema, [key, value]) => {
).reduce((acc: BaseOutputSchema, [key, value]) => {
acc[key] = { isLeaf: true, value };
return acc;
}, {});
const baseResult: OutputSchema = {
recordId: { isLeaf: true, type: 'string', value: recordId },
userId: { isLeaf: true, type: 'string', value: userId },
const baseResult: BaseOutputSchema = {
recordId: {
isLeaf: true,
type: 'string',
value: recordId,
label: 'Record ID',
},
userId: { isLeaf: true, type: 'string', value: userId, label: 'User ID' },
workspaceMemberId: {
isLeaf: true,
type: 'string',
value: workspaceMemberId,
label: 'Workspace Member ID',
},
objectMetadata: {
isLeaf: false,
value: formattedObjectMetadataEntity,
label: 'Object Metadata',
},
};
@ -41,7 +48,8 @@ export const generateFakeObjectRecordEvent = (
...baseResult,
properties: {
isLeaf: false,
value: { after: { isLeaf: false, value: after } },
value: { after: { isLeaf: false, value: after, label: 'After' } },
label: 'Properties',
},
};
}
@ -54,9 +62,10 @@ export const generateFakeObjectRecordEvent = (
properties: {
isLeaf: false,
value: {
before: { isLeaf: false, value: before },
after: { isLeaf: false, value: after },
before: { isLeaf: false, value: before, label: 'Before' },
after: { isLeaf: false, value: after, label: 'After' },
},
label: 'Properties',
},
};
}
@ -67,8 +76,9 @@ export const generateFakeObjectRecordEvent = (
properties: {
isLeaf: false,
value: {
before: { isLeaf: false, value: before },
before: { isLeaf: false, value: before, label: 'Before' },
},
label: 'Properties',
},
};
}
@ -79,8 +89,9 @@ export const generateFakeObjectRecordEvent = (
properties: {
isLeaf: false,
value: {
before: { isLeaf: false, value: before },
before: { isLeaf: false, value: before, label: 'Before' },
},
label: 'Properties',
},
};
}

View File

@ -1,13 +1,19 @@
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import {
Leaf,
Node,
RecordOutputSchema,
} from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { shouldGenerateFieldFakeValue } from 'src/modules/workflow/workflow-builder/utils/should-generate-field-fake-value';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { camelToTitleCase } from 'src/utils/camel-to-title-case';
export const generateFakeObjectRecord = (
const generateObjectRecordFields = (
objectMetadataEntity: ObjectMetadataEntity,
): OutputSchema =>
objectMetadataEntity.fields.reduce((acc: OutputSchema, field) => {
) =>
objectMetadataEntity.fields.reduce(
(acc: Record<string, Leaf | Node>, field) => {
if (!shouldGenerateFieldFakeValue(field)) {
return acc;
}
@ -18,16 +24,19 @@ export const generateFakeObjectRecord = (
isLeaf: true,
type: field.type,
icon: field.icon,
label: field.label,
value: generateFakeValue(field.type),
};
} else {
acc[field.name] = {
isLeaf: false,
icon: field.icon,
label: field.label,
value: compositeType.properties.reduce((acc, property) => {
acc[property.name] = {
isLeaf: true,
type: property.type,
label: camelToTitleCase(property.name),
value: generateFakeValue(property.type),
};
@ -37,4 +46,21 @@ export const generateFakeObjectRecord = (
}
return acc;
}, {});
},
{},
);
export const generateFakeObjectRecord = (
objectMetadataEntity: ObjectMetadataEntity,
): RecordOutputSchema => ({
object: {
isLeaf: true,
icon: objectMetadataEntity.icon,
label: objectMetadataEntity.labelSingular,
value: objectMetadataEntity.description,
nameSingular: objectMetadataEntity.nameSingular,
fieldIdName: 'id',
},
fields: generateObjectRecordFields(objectMetadataEntity),
_outputSchemaType: 'RECORD',
});

View File

@ -5,7 +5,7 @@ import {
export const shouldGenerateFieldFakeValue = (field: FieldMetadataEntity) => {
return (
!field.isSystem &&
(!field.isSystem || field.name === 'id') &&
field.isActive &&
field.type !== FieldMetadataType.RELATION
);

View File

@ -12,6 +12,12 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
import { InputSchemaPropertyType } from 'src/modules/code-introspection/types/input-schema.type';
import {
Leaf,
Node,
OutputSchema,
} from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { generateFakeObjectRecord } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record';
import { generateFakeObjectRecordEvent } from 'src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event';
import { WorkflowRecordCRUDType } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-input.type';
@ -24,8 +30,6 @@ import {
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
import { isDefined } from 'src/utils/is-defined';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/types/output-schema.type';
import { InputSchemaPropertyType } from 'src/modules/code-introspection/types/input-schema.type';
@Injectable()
export class WorkflowBuilderWorkspaceService {
@ -145,7 +149,11 @@ export class WorkflowBuilderWorkspaceService {
if (operationType === WorkflowRecordCRUDType.READ) {
return {
first: { isLeaf: false, icon: 'IconAlpha', value: recordOutputSchema },
first: {
isLeaf: false,
icon: 'IconAlpha',
value: recordOutputSchema,
},
last: { isLeaf: false, icon: 'IconOmega', value: recordOutputSchema },
totalCount: {
isLeaf: true,
@ -231,7 +239,7 @@ export class WorkflowBuilderWorkspaceService {
return resultFromFakeInput.data
? Object.entries(resultFromFakeInput.data).reduce(
(acc: OutputSchema, [key, value]) => {
(acc: Record<string, Leaf | Node>, [key, value]) => {
acc[key] = {
isLeaf: true,
value,

View File

@ -1,13 +1,14 @@
import { JSX } from 'react';
import styled from '@emotion/styled';
import { JSX } from 'react';
type HorizontalSeparatorProps = {
visible?: boolean;
text?: string;
noMargin?: boolean;
color?: string;
};
const StyledSeparator = styled.div<HorizontalSeparatorProps>`
background-color: ${({ theme }) => theme.border.color.medium};
background-color: ${({ theme, color }) => color ?? theme.border.color.medium};
height: ${({ visible }) => (visible ? '1px' : 0)};
margin-bottom: ${({ theme, noMargin }) => (noMargin ? 0 : theme.spacing(3))};
margin-top: ${({ theme, noMargin }) => (noMargin ? 0 : theme.spacing(3))};
@ -38,6 +39,7 @@ export const HorizontalSeparator = ({
visible = true,
text = '',
noMargin = false,
color,
}: HorizontalSeparatorProps): JSX.Element => (
<>
{text ? (
@ -47,7 +49,7 @@ export const HorizontalSeparator = ({
<StyledLine visible={visible} />
</StyledSeparatorContainer>
) : (
<StyledSeparator visible={visible} noMargin={noMargin} />
<StyledSeparator visible={visible} noMargin={noMargin} color={color} />
)}
</>
);