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:
committed by
GitHub
parent
834d354d46
commit
99d60cd48b
+80
-43
@@ -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) && (
|
||||
|
||||
+130
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user