Display system objects in Workflow triggers (#11314)

Fixes #11310

Note:
- I changed "system to advanced" because I think we will link that to
the Advanced Mode in the future
- Not sure when to use useCallback / useMemo, AI added some useCallbacks
which I removed but I left some useMemo... Not sure what should be the
rule
- It had to use MenuItem because this sub-menu behavior wasn't available
in the Standard select component. We should probably rename the
"MenuItem" elements to something more generic. I didn't do it in this PR
because I'm not sure about the strategy and it would change a lot of
files.
This commit is contained in:
Félix Malfait
2025-04-01 11:48:52 +02:00
committed by GitHub
parent a26b3f54d6
commit b2012229f4

View File

@ -1,5 +1,13 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
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 { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
@ -7,8 +15,34 @@ import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/Workflo
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerLabel';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
import { SelectOption, useIcons } from 'twenty-ui';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
import { useCallback, useMemo, useState } from 'react';
import { IconChevronLeft, IconSettings, MenuItem, useIcons } from 'twenty-ui';
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 StyledContainer = styled.div<{ fullWidth?: boolean }>`
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
`;
const DEFAULT_SELECTED_OPTION = { label: 'Select an option', value: '' };
const filterOptionsBySearch = <T extends { label: string }>(
options: T[],
searchValue: string,
): T[] => {
if (!searchValue) return options;
return options.filter((option) =>
option.label.toLowerCase().includes(searchValue.toLowerCase()),
);
};
type WorkflowEditTriggerDatabaseEventFormProps = {
trigger: WorkflowDatabaseEventTrigger;
@ -29,20 +63,47 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
}: WorkflowEditTriggerDatabaseEventFormProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const [searchInputValue, setSearchInputValue] = useState('');
const [isSystemObjectsOpen, setIsSystemObjectsOpen] = useState(false);
const { closeDropdown } = useDropdown('workflow-edit-trigger-record-type');
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const { objectMetadataItems } = useFilteredObjectMetadataItems();
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
const availableMetadata: Array<SelectOption<string>> =
activeObjectMetadataItems.map((item) => ({
const regularObjects = objectMetadataItems
.filter((item) => item.isActive && !item.isSystem)
.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
Icon: getIcon(item.icon),
}));
const systemObjects = objectMetadataItems
.filter((item) => item.isActive && item.isSystem)
.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
Icon: getIcon(item.icon),
}));
const selectedOption =
[...regularObjects, ...systemObjects].find(
(option) => option.value === triggerEvent?.objectType,
) || DEFAULT_SELECTED_OPTION;
const filteredRegularObjects = useMemo(
() => filterOptionsBySearch(regularObjects, searchInputValue),
[regularObjects, searchInputValue],
);
const filteredSystemObjects = useMemo(
() => filterOptionsBySearch(systemObjects, searchInputValue),
[systemObjects, searchInputValue],
);
const defaultLabel =
getTriggerDefaultLabel({
type: 'DATABASE_EVENT',
@ -54,10 +115,40 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
eventName: triggerEvent.event,
});
const headerTitle = isDefined(trigger.name) ? trigger.name : defaultLabel;
const headerType = `Trigger · ${defaultLabel}`;
const handleOptionClick = (value: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
eventName: `${value}.${triggerEvent.event}`,
},
});
closeDropdown();
};
const handleSystemObjectsClick = () => {
setIsSystemObjectsOpen(true);
setSearchInputValue('');
};
const handleBack = () => {
setIsSystemObjectsOpen(false);
setSearchInputValue('');
};
const handleSearchInputChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setSearchInputValue(event.target.value);
},
[],
);
return (
<>
<WorkflowStepHeader
@ -73,34 +164,90 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
}}
Icon={getIcon(headerIcon)}
iconColor={theme.font.color.tertiary}
initialTitle={headerTitle}
initialTitle={defaultLabel}
headerType={headerType}
disabled={triggerOptions.readonly}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={triggerOptions.readonly}
value={triggerEvent?.objectType}
emptyOption={{ label: 'Select an option', value: '' }}
options={availableMetadata}
onChange={(updatedRecordType) => {
if (triggerOptions.readonly === true) {
return;
<StyledContainer fullWidth>
<StyledLabel>Record Type</StyledLabel>
<Dropdown
dropdownId="workflow-edit-trigger-record-type"
dropdownMenuWidth={300}
dropdownPlacement="bottom-start"
clickableComponent={
<SelectControl
isDisabled={triggerOptions.readonly}
selectedOption={selectedOption}
/>
}
triggerOptions.onTriggerUpdate({
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
});
}}
withSearchInput
/>
dropdownComponents={
<>
{isSystemObjectsOpen ? (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleBack}
Icon={IconChevronLeft}
/>
}
>
<Trans>Advanced</Trans>
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={handleSearchInputChange}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredSystemObjects.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => handleOptionClick(option.value)}
/>
))}
</DropdownMenuItemsContainer>
</>
) : (
<>
<DropdownMenuSearchInput
autoFocus
value={searchInputValue}
onChange={handleSearchInputChange}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredRegularObjects.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => handleOptionClick(option.value)}
/>
))}
{(!searchInputValue ||
'advanced'.includes(
searchInputValue.toLowerCase(),
)) && (
<MenuItem
text="Advanced"
LeftIcon={IconSettings}
onClick={handleSystemObjectsClick}
hasSubMenu
/>
)}
</DropdownMenuItemsContainer>
</>
)}
</>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</StyledContainer>
</WorkflowStepBody>
</>
);