Adding system objects to workflow search node (v2) (#15965)

This PR adds support for selecting system objects in the workflow Find
Records action.

The implementation includes extracting duplicated dropdown code into a
reusable component and fixing several code quality issues identified in
previous PR feedback.

All code quality concerns have been addressed: proper TypeScript types,
utility function usage, correct memoization, and improved variable
naming.



https://github.com/user-attachments/assets/8d0bd052-d3dd-4e3e-8379-35bfcb18babc

<img width="514" height="812" alt="CleanShot 2025-11-20 at 16 55 14"
src="https://github.com/user-attachments/assets/4b3617e6-628f-4368-be0c-fc87ef4c4ea9"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas des Francs
2025-11-24 11:48:55 +01:00
committed by GitHub
parent 834d354d46
commit 99d60cd48b
2 changed files with 210 additions and 43 deletions
@@ -1,8 +1,15 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { type WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { isNumber } from '@sniptt/guards';
import { useEffect, useState } from 'react';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
@@ -14,19 +21,30 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters';
import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
import { WorkflowFindRecordsSorts } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsSorts';
import { useLingui } from '@lingui/react/macro';
import { isNumber } from '@sniptt/guards';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { HorizontalSeparator, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
import { WorkflowObjectDropdownContent } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowObjectDropdownContent';
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledRecordTypeSelectContainer = styled.div<{ fullWidth?: boolean }>`
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
`;
const DEFAULT_SELECTED_OPTION = { label: 'Select an option', value: '' };
type WorkflowEditActionFindRecordsProps = {
action: WorkflowFindRecordsAction;
@@ -62,19 +80,15 @@ export const WorkflowEditActionFindRecords = ({
action,
actionOptions,
}: WorkflowEditActionFindRecordsProps) => {
const { getIcon } = useIcons();
const theme = useTheme();
const { t } = useLingui();
const maxRecordsFormatted = QUERY_MAX_RECORDS.toLocaleString();
const { activeNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const dropdownId = 'workflow-edit-action-record-find-records-object-name';
const availableMetadata: Array<SelectOption<string>> =
activeNonSystemObjectMetadataItems.map((item) => ({
Icon: getIcon(item.icon),
label: item.labelPlural,
value: item.nameSingular,
}));
const { closeDropdown } = useCloseDropdown();
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const [formData, setFormData] = useState<FindRecordsFormData>(() => ({
objectNameSingular: action.settings.input.objectName,
@@ -86,16 +100,23 @@ export const WorkflowEditActionFindRecords = ({
filter: action.settings.input.filter as FindRecordsActionFilter,
orderBy: action.settings.input.orderBy as FindRecordsActionOrderBy,
}));
const [limitError, setLimitError] = useState<string | undefined>(undefined);
const isFormDisabled = actionOptions.readonly ?? false;
const instanceId = `workflow-edit-action-record-find-records-${action.id}-${formData.objectNameSingular}`;
const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find(
const selectedObjectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === formData.objectNameSingular,
);
const { getIcon } = useIcons();
const selectedObjectMetadataItemNameSingular =
selectedObjectMetadataItem?.nameSingular ?? '';
const selectedOption = selectedObjectMetadataItem
? {
Icon: getIcon(selectedObjectMetadataItem?.icon),
label: selectedObjectMetadataItem?.labelPlural,
value: selectedObjectMetadataItem?.nameSingular,
}
: DEFAULT_SELECTED_OPTION;
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
@@ -144,29 +165,45 @@ export const WorkflowEditActionFindRecords = ({
};
}, [saveAction]);
const handleOptionClick = (value: string) => {
if (actionOptions.readonly === true) {
return;
}
const newFormData: FindRecordsFormData = {
objectNameSingular: value,
limit: 1,
};
setFormData(newFormData);
saveAction(newFormData);
closeDropdown(dropdownId);
};
return (
<>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-find-records-object-name"
label="Object"
fullWidth
disabled={isFormDisabled}
value={selectedObjectMetadataItemNameSingular}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(objectNameSingular) => {
const newFormData: FindRecordsFormData = {
objectNameSingular,
limit: 1,
};
setFormData(newFormData);
saveAction(newFormData);
}}
withSearchInput
/>
<StyledRecordTypeSelectContainer fullWidth>
<StyledLabel>Object</StyledLabel>
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<SelectControl
isDisabled={isFormDisabled}
selectedOption={selectedOption}
/>
}
dropdownComponents={
!isFormDisabled && (
<WorkflowObjectDropdownContent
onOptionClick={handleOptionClick}
/>
)
}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
/>
</StyledRecordTypeSelectContainer>
<HorizontalSeparator noMargin />
{isDefined(selectedObjectMetadataItem) && (
@@ -0,0 +1,130 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { IconChevronLeft, IconSettings, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type WorkflowObjectDropdownContentProps = {
onOptionClick: (value: string) => void;
showAdvancedOption?: boolean;
};
export const WorkflowObjectDropdownContent = ({
onOptionClick,
showAdvancedOption = true,
}: WorkflowObjectDropdownContentProps) => {
const { getIcon } = useIcons();
const [searchInputValue, setSearchInputValue] = useState('');
const [isSystemObjectsOpen, setIsSystemObjectsOpen] = useState(false);
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const nonSystemObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
objectMetadataItem.isActive && !objectMetadataItem.isSystem,
);
const systemObjectMetadataItems = objectMetadataItems.filter(
(objectMetadataItem) =>
objectMetadataItem.isActive && objectMetadataItem.isSystem,
);
const shouldShowAdvanced =
showAdvancedOption &&
!isSystemObjectsOpen &&
(!isNonEmptyString(searchInputValue) ||
searchInputValue.toLowerCase().includes('advanced'));
const filteredNonSystemObjects = nonSystemObjectMetadataItems.filter(
(objectMetadataItem) =>
objectMetadataItem.nameSingular
.toLowerCase()
.includes(searchInputValue.toLowerCase()),
);
const filteredSystemObjects = systemObjectMetadataItems.filter(
(objectMetadataItem) =>
objectMetadataItem.nameSingular
.toLowerCase()
.includes(searchInputValue.toLowerCase()),
);
const filteredObjects = isSystemObjectsOpen
? filteredSystemObjects
: filteredNonSystemObjects;
const filteredOptions = filteredObjects.map((objectMetadataItem) => ({
Icon: getIcon(objectMetadataItem.icon),
label: objectMetadataItem.labelPlural,
value: objectMetadataItem.nameSingular,
}));
const handleSystemObjectsClick = () => {
setIsSystemObjectsOpen(true);
setSearchInputValue('');
};
const handleBack = () => {
setIsSystemObjectsOpen(false);
setSearchInputValue('');
};
const handleSearchInputChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setSearchInputValue(event.target.value);
};
const handleAdvancedClick = () => {
if (!isSystemObjectsOpen) {
handleSystemObjectsClick();
}
};
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
{isSystemObjectsOpen && (
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleBack}
Icon={IconChevronLeft}
/>
}
>
<Trans>Advanced</Trans>
</DropdownMenuHeader>
)}
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={handleSearchInputChange}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredOptions.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => onOptionClick(option.value)}
/>
))}
{shouldShowAdvanced && (
<MenuItem
text={<Trans>Advanced</Trans>}
LeftIcon={IconSettings}
onClick={handleAdvancedClick}
hasSubMenu
/>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};