From 36e4357bb1edb08121b0c07c55e84707c9601bde Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 5 Dec 2024 10:48:34 +0100 Subject: [PATCH] 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 --- .../WorkflowSingleRecordFieldChip.tsx | 8 +- .../components/WorkflowSingleRecordPicker.tsx | 54 ++--- .../components/SearchVariablesDropdown.tsx | 70 +++++-- .../SearchVariablesDropdownFieldItems.tsx | 133 +++++++++++++ .../SearchVariablesDropdownObjectItems.tsx | 159 +++++++++++++++ .../SearchVariablesDropdownStepItem.tsx | 36 ---- .../SearchVariablesDropdownStepSubItem.tsx | 92 --------- ...archVariablesDropdownWorkflowStepItems.tsx | 79 ++++++++ .../useAvailableVariablesInWorkflowStep.ts | 37 +++- .../types/StepOutputSchema.ts | 16 +- .../__tests__/filterOutputSchema.test.ts | 185 ++++++++++++++++++ .../utils/filterOutputSchema.ts | 115 +++++++++++ .../utils/isBaseOutputSchema.ts | 10 + .../utils/isRecordOutputSchema.ts | 10 + .../src/engine/utils/generate-fake-value.ts | 5 + ...workflow-version-step.workspace-service.ts | 24 +-- .../types/output-schema.type.ts | 16 +- .../generate-fake-object-record-event.ts | 33 ++-- .../utils/generate-fake-object-record.ts | 94 +++++---- .../utils/should-generate-field-fake-value.ts | 2 +- .../workflow-builder.workspace-service.ts | 16 +- .../text/components/HorizontalSeparator.tsx | 8 +- 22 files changed, 934 insertions(+), 268 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownFieldItems.tsx create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownObjectItems.tsx delete mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx delete mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownWorkflowStepItems.tsx create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/filterOutputSchema.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/filterOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/isBaseOutputSchema.ts create mode 100644 packages/twenty-front/src/modules/workflow/search-variables/utils/isRecordOutputSchema.ts diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordFieldChip.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordFieldChip.tsx index b1d1fbf925..654e13ffed 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordFieldChip.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordFieldChip.tsx @@ -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 ( - + ); } diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordPicker.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordPicker.tsx index c97167aa6d..1f6711521e 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordPicker.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowSingleRecordPicker.tsx @@ -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} /> diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx index b20d6695b2..965a41f40a 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdown.tsx @@ -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 ( + + ); + } + + if (isDefined(objectNameSingularToSelect)) { + return ( + + ); + } + + return ( + + ); + }; + if (disabled === true) { return ( } dropdownComponents={ - - {selectedStep ? ( - - ) : ( - - )} - + + {renderSearchVariablesDropdownComponents()} + } dropdownPlacement="bottom-end" dropdownOffset={{ x: 0, y: 4 }} diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownFieldItems.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownFieldItems.tsx new file mode 100644 index 0000000000..8dcd83572d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownFieldItems.tsx @@ -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([]); + 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 ( + + + + + + setSearchInputValue(event.target.value)} + /> + + {filteredOptions.map(([key, value]) => ( + handleSelectField(key)} + text={value.label || key} + hasSubMenu={!value.isLeaf} + LeftIcon={value.icon ? getIcon(value.icon) : undefined} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownObjectItems.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownObjectItems.tsx new file mode 100644 index 0000000000..92aace99af --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownObjectItems.tsx @@ -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([]); + 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 ( + + + + + + setSearchInputValue(event.target.value)} + /> + + {shouldDisplaySubStepObject && displayedSubStepObject?.label && ( + + )} + {filteredOptions.map(([key, value]) => ( + handleSelectField(key)} + text={value.label || key} + hasSubMenu={!value.isLeaf} + LeftIcon={value.icon ? getIcon(value.icon) : undefined} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx deleted file mode 100644 index 2a62fa8cfc..0000000000 --- a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepItem.tsx +++ /dev/null @@ -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) => ( - onSelect(item.id)} - text={item.name} - LeftIcon={undefined} - hasSubMenu - /> - ))} - - ) : ( - {}} - text="No variables available" - LeftIcon={undefined} - hasSubMenu={false} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx deleted file mode 100644 index 5b135a1834..0000000000 --- a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownStepSubItem.tsx +++ /dev/null @@ -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([]); - 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 ( - <> - - - - setSearchInputValue(event.target.value)} - /> - {filteredOptions.map(([key, value]) => ( - handleSelect(key)} - text={key} - hasSubMenu={!value.isLeaf} - LeftIcon={value.icon ? getIcon(value.icon) : undefined} - /> - ))} - - ); -}; - -export default SearchVariablesDropdownStepSubItem; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownWorkflowStepItems.tsx b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownWorkflowStepItems.tsx new file mode 100644 index 0000000000..4c0520752c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/components/SearchVariablesDropdownWorkflowStepItems.tsx @@ -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 ( + + + + + + setSearchInputValue(event.target.value)} + /> + + {availableSteps.length > 0 ? ( + availableSteps.map((item, _index) => ( + onSelect(item.id)} + text={item.name} + LeftIcon={undefined} + hasSubMenu + /> + )) + ) : ( + {}} + text="No variables available" + LeftIcon={undefined} + hasSubMenu={false} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/hooks/useAvailableVariablesInWorkflowStep.ts b/packages/twenty-front/src/modules/workflow/search-variables/hooks/useAvailableVariablesInWorkflowStep.ts index 320fce7e95..10c0a9964f 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/hooks/useAvailableVariablesInWorkflowStep.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/hooks/useAvailableVariablesInWorkflowStep.ts @@ -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, }); } }); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/types/StepOutputSchema.ts b/packages/twenty-front/src/modules/workflow/search-variables/types/StepOutputSchema.ts index d322579d89..6268184ac5 100644 --- a/packages/twenty-front/src/modules/workflow/search-variables/types/StepOutputSchema.ts +++ b/packages/twenty-front/src/modules/workflow/search-variables/types/StepOutputSchema.ts @@ -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; +export type BaseOutputSchema = Record; + +export type RecordOutputSchema = { + object: { nameSingular: string; fieldIdName: string } & Leaf; + fields: BaseOutputSchema; + _outputSchemaType: 'RECORD'; +}; + +export type OutputSchema = BaseOutputSchema | RecordOutputSchema; export type StepOutputSchema = { id: string; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/filterOutputSchema.test.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/filterOutputSchema.test.ts new file mode 100644 index 0000000000..90d7724920 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/__tests__/filterOutputSchema.test.ts @@ -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: {}, + }, + }, + }, + }, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/filterOutputSchema.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/filterOutputSchema.ts new file mode 100644 index 0000000000..1129d6df78 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/filterOutputSchema.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/isBaseOutputSchema.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/isBaseOutputSchema.ts new file mode 100644 index 0000000000..007bfeca6a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/isBaseOutputSchema.ts @@ -0,0 +1,10 @@ +import { + BaseOutputSchema, + OutputSchema, +} from '@/workflow/search-variables/types/StepOutputSchema'; + +export const isBaseOutputSchema = ( + outputSchema: OutputSchema, +): outputSchema is BaseOutputSchema => { + return !outputSchema._outputSchemaType; +}; diff --git a/packages/twenty-front/src/modules/workflow/search-variables/utils/isRecordOutputSchema.ts b/packages/twenty-front/src/modules/workflow/search-variables/utils/isRecordOutputSchema.ts new file mode 100644 index 0000000000..6ce193ca47 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/search-variables/utils/isRecordOutputSchema.ts @@ -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'; +}; diff --git a/packages/twenty-server/src/engine/utils/generate-fake-value.ts b/packages/twenty-server/src/engine/utils/generate-fake-value.ts index 29532b07db..e29c0dc9a1 100644 --- a/packages/twenty-server/src/engine/utils/generate-fake-value.ts +++ b/packages/twenty-server/src/engine/utils/generate-fake-value.ts @@ -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'; } diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service.ts index 6ffdc311e1..2f5511525d 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-version-step.workspace-service.ts @@ -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'; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/types/output-schema.type.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/types/output-schema.type.ts index 061f1cc749..852317dc5a 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/types/output-schema.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/types/output-schema.type.ts @@ -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; +export type BaseOutputSchema = Record; + +export type RecordOutputSchema = { + object: { nameSingular: string; fieldIdName: string } & Leaf; + fields: BaseOutputSchema; + _outputSchemaType: 'RECORD'; +}; + +export type OutputSchema = BaseOutputSchema | RecordOutputSchema; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event.ts index 5a9850e613..0f297482b4 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record-event.ts @@ -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', }, }; } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record.ts index 5b61eda5e5..945f8a3e0e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/generate-fake-object-record.ts @@ -1,40 +1,66 @@ +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'; + +const generateObjectRecordFields = ( + objectMetadataEntity: ObjectMetadataEntity, +) => + objectMetadataEntity.fields.reduce( + (acc: Record, field) => { + if (!shouldGenerateFieldFakeValue(field)) { + return acc; + } + const compositeType = compositeTypeDefinitions.get(field.type); + + if (!compositeType) { + acc[field.name] = { + 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), + }; + + return acc; + }, {}), + }; + } + + return acc; + }, + {}, + ); export const generateFakeObjectRecord = ( objectMetadataEntity: ObjectMetadataEntity, -): OutputSchema => - objectMetadataEntity.fields.reduce((acc: OutputSchema, field) => { - if (!shouldGenerateFieldFakeValue(field)) { - return acc; - } - const compositeType = compositeTypeDefinitions.get(field.type); - - if (!compositeType) { - acc[field.name] = { - isLeaf: true, - type: field.type, - icon: field.icon, - value: generateFakeValue(field.type), - }; - } else { - acc[field.name] = { - isLeaf: false, - icon: field.icon, - value: compositeType.properties.reduce((acc, property) => { - acc[property.name] = { - isLeaf: true, - type: property.type, - value: generateFakeValue(property.type), - }; - - return acc; - }, {}), - }; - } - - return acc; - }, {}); +): RecordOutputSchema => ({ + object: { + isLeaf: true, + icon: objectMetadataEntity.icon, + label: objectMetadataEntity.labelSingular, + value: objectMetadataEntity.description, + nameSingular: objectMetadataEntity.nameSingular, + fieldIdName: 'id', + }, + fields: generateObjectRecordFields(objectMetadataEntity), + _outputSchemaType: 'RECORD', +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/should-generate-field-fake-value.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/should-generate-field-fake-value.ts index 7315b36fe8..37ce8c022c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/utils/should-generate-field-fake-value.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/utils/should-generate-field-fake-value.ts @@ -5,7 +5,7 @@ import { export const shouldGenerateFieldFakeValue = (field: FieldMetadataEntity) => { return ( - !field.isSystem && + (!field.isSystem || field.name === 'id') && field.isActive && field.type !== FieldMetadataType.RELATION ); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts index 1b40294475..0b213deb05 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-builder.workspace-service.ts @@ -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, [key, value]) => { acc[key] = { isLeaf: true, value, diff --git a/packages/twenty-ui/src/display/text/components/HorizontalSeparator.tsx b/packages/twenty-ui/src/display/text/components/HorizontalSeparator.tsx index 1e705663e7..52c00f9246 100644 --- a/packages/twenty-ui/src/display/text/components/HorizontalSeparator.tsx +++ b/packages/twenty-ui/src/display/text/components/HorizontalSeparator.tsx @@ -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` - 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 = ({ ) : ( - + )} );