Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3fc1b1d
feat: add metadata eventing via webhooks
mabdullahabaid Feb 2, 2026
776d643
feat: add userId and workspaceMemberId to metadata events for attribu…
mabdullahabaid Feb 2, 2026
4d54144
Merge branch 'main' into metadata-events
mabdullahabaid Feb 2, 2026
16a4343
chore: run the linter
mabdullahabaid Feb 2, 2026
595180e
fix: use type instead of enum
mabdullahabaid Feb 2, 2026
187c769
Merge branch 'main' into metadata-events
mabdullahabaid Feb 2, 2026
3568b13
fix: add userId and workspaceMetadataId to metadata events for attrib…
mabdullahabaid Feb 2, 2026
2769dca
fix: run the linter and resolve typecheck issues
mabdullahabaid Feb 2, 2026
96fa444
Merge branch 'main' into metadata-events
FelixMalfait Feb 2, 2026
d4d57b8
fix: nested field metadata event not being fired on object creation
mabdullahabaid Feb 2, 2026
9419a99
feat: ui changes for supporting metadata-events with webhooks
mabdullahabaid Feb 3, 2026
4910188
Merge branch 'main' into metadata-events
FelixMalfait Feb 3, 2026
04a70e1
fix: resolve issues with the the UI
mabdullahabaid Feb 3, 2026
bf7714a
fix: remove userId and workspaceMemberId drilling
mabdullahabaid Feb 3, 2026
640d2cc
fix: ensure auth context is used to fetch userId
mabdullahabaid Feb 3, 2026
b476954
fix: resolve the issue with event not being sent over webhook for upd…
mabdullahabaid Feb 3, 2026
7549286
chore: improve metadata events type system
mabdullahabaid Feb 3, 2026
94a6390
chore: rename function
mabdullahabaid Feb 3, 2026
dccc5af
fix: imports should not be type
mabdullahabaid Feb 3, 2026
f143d7a
refactor(server): emitter service
prastoin Feb 4, 2026
f2a49e7
Merge remote-tracking branch 'origin/main' into metadata-events
prastoin Feb 4, 2026
fd6ab9d
feat(server): refactoring to be byUniversal compatible
prastoin Feb 4, 2026
b5d0d57
refactor(server): create
prastoin Feb 4, 2026
e019a94
feat(server): wip
prastoin Feb 4, 2026
afc5400
Merge branch 'main' into metadata-events
mabdullahabaid Feb 10, 2026
34f805c
refactor(server): finalize rebase
prastoin Feb 10, 2026
f4bb67a
Merge remote-tracking branch 'origin/main' into metadata-events
prastoin Feb 10, 2026
a321ab0
Merge branch 'main' into metadata-events
mabdullahabaid Feb 11, 2026
74dd8f0
feat: rework metadata-events with flat types and single source of tru…
mabdullahabaid Feb 11, 2026
c41d4c4
feat: rework metadata event types with distributive envelope and stri…
mabdullahabaid Feb 11, 2026
20f4eb4
feat: add per-property typed update diffs with FlatEntityUpdateKey co…
mabdullahabaid Feb 11, 2026
9b3413a
refactor(server): revert runner
prastoin Feb 11, 2026
37b26e9
refactor
prastoin Feb 11, 2026
8989755
chore
prastoin Feb 11, 2026
4b3c447
fixup
prastoin Feb 11, 2026
2c7fa97
fix
prastoin Feb 11, 2026
b0ba022
Merge branch 'main' into metadata-events
mabdullahabaid Feb 12, 2026
7470551
feat: introduce CallMetadataWebhookJobData to preserve full MetadataE…
mabdullahabaid Feb 12, 2026
78bf547
chore: run the linter
mabdullahabaid Feb 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { WebhookEntitySelect } from '@/settings/developers/components/WebhookEntitySelect';
import { Select } from '@/ui/input/components/Select';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconBox,
IconNorthStar,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { IconBox, IconNorthStar, IconPlus, IconTrash } from 'twenty-ui/display';
import { IconButton, type SelectOption } from 'twenty-ui/input';

const OBJECT_DROPDOWN_WIDTH = 240;
Expand Down Expand Up @@ -51,19 +45,6 @@ export const SettingsDatabaseEventsForm = ({
}) => {
const isMobile = useIsMobile();

const { objectMetadataItems } = useObjectMetadataItems();

const { getIcon } = useIcons();

const objectOptions: SelectOption<string>[] = [
{ label: t`All Objects`, value: '*', Icon: IconNorthStar },
...objectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
Icon: getIcon(item.icon),
})),
];

const getActionOptions = (
updatedFields?: string[],
): SelectOption<string>[] => {
Expand All @@ -86,15 +67,12 @@ export const SettingsDatabaseEventsForm = ({
<>
{events.map((operation, index) => (
<StyledFilterRow key={index} isMobile={isMobile}>
<Select
<WebhookEntitySelect
Comment thread
mabdullahabaid marked this conversation as resolved.
dropdownId={`object-webhook-type-select-${index}`}
value={operation.object}
options={objectOptions}
onChange={(newValue) =>
updateOperation?.(index, 'object', newValue)
}
fullWidth
emptyOption={{ label: t`Object`, value: null }}
disabled={disabled}
/>
<Select
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
IconBox,
IconChevronDown,
IconCode,
IconEye,
IconNorthStar,
IconSettings,
IconTable,
useIcons,
} from 'twenty-ui/display';
import { MenuItemSelect } from 'twenty-ui/navigation';

const WEBHOOK_ENTITY_DROPDOWN_ID = 'webhook-entity-select';

const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
width: 100%;

&:hover {
background-color: ${({ theme, disabled }) =>
disabled
? theme.background.transparent.lighter
: theme.background.transparent.light};
}
`;

const StyledControlLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

const StyledControlIconChevronDown = styled(IconChevronDown)<{
disabled?: boolean;
}>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;

type WebhookEntitySelectProps = {
value: string | null;
onChange: (value: string | null) => void;
disabled?: boolean;
dropdownId?: string;
};

export const WebhookEntitySelect = ({
value,
onChange,
disabled = false,
dropdownId = WEBHOOK_ENTITY_DROPDOWN_ID,
}: WebhookEntitySelectProps) => {
const theme = useTheme();
const [searchInput, setSearchInput] = useState('');
const { objectMetadataItems } = useObjectMetadataItems();
const { getIcon } = useIcons();
const { closeDropdown } = useCloseDropdown();

const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
);

const metadataOptions = [
{ label: t`All Metadata`, value: 'metadata.*', icon: IconNorthStar },
{ label: t`Object`, value: 'metadata.objectMetadata', icon: IconTable },
{ label: t`Field`, value: 'metadata.fieldMetadata', icon: IconBox },
{ label: t`View`, value: 'metadata.view', icon: IconEye },
{ label: t`View Field`, value: 'metadata.viewField', icon: IconEye },
{ label: t`Index`, value: 'metadata.index', icon: IconSettings },
{ label: t`Webhook`, value: 'metadata.webhook', icon: IconCode },
];

const objectOptions = [
{ label: t`All Objects`, value: '*', Icon: IconNorthStar },
...objectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.nameSingular,
Icon: getIcon(item.icon),
})),
];

const filteredObjectOptions = objectOptions.filter((option) =>
option.label.toLowerCase().includes(searchInput.toLowerCase()),
);

const filteredMetadataOptions = metadataOptions.filter((option) =>
option.label.toLowerCase().includes(searchInput.toLowerCase()),
);

// Find selected option label for display
const getSelectedLabel = () => {
if (!isDefined(value)) {
return t`Select entity`;
}
if (value === '*') {
return t`All Objects`;
}

const metadataOption = metadataOptions.find((opt) => opt.value === value);
if (isDefined(metadataOption)) {
return metadataOption.label;
}

const objectOption = objectOptions.find((opt) => opt.value === value);
if (isDefined(objectOption)) {
return objectOption.label;
}

return value;
};

const handleSelect = (selectedValue: string) => {
if (disabled) return;
onChange(selectedValue);
closeDropdown(dropdownId);
setSearchInput('');
};

const shouldShowObjects = filteredObjectOptions.length > 0;
const shouldShowMetadata = filteredMetadataOptions.length > 0;
const shouldShowSeparator = shouldShowObjects && shouldShowMetadata;

const selectableItemIds = [
...filteredObjectOptions.map((opt) => opt.value),
...filteredMetadataOptions.map((opt) => opt.value),
];

return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
disableClickForClickableComponent={disabled}
onClose={() => setSearchInput('')}
clickableComponent={
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>{getSelectedLabel()}</StyledControlLabel>
<StyledControlIconChevronDown
disabled={disabled}
size={theme.icon.size.md}
/>
</StyledControlContainer>
}
dropdownComponents={
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<DropdownMenuSearchInput
autoFocus
value={searchInput}
placeholder={t`Search...`}
onChange={(event) => setSearchInput(event.target.value)}
/>
<DropdownMenuSeparator />
<SelectableList
selectableListInstanceId={dropdownId}
selectableItemIdArray={selectableItemIds}
focusId={dropdownId}
>
<DropdownMenuItemsContainer hasMaxHeight>
{shouldShowObjects && (
<>
<DropdownMenuSectionLabel label={t`Core Objects`} />
{filteredObjectOptions.map((option) => (
<SelectableListItem
key={option.value}
itemId={option.value}
onEnter={() => handleSelect(option.value)}
>
<MenuItemSelect
LeftIcon={option.Icon}
text={option.label}
selected={value === option.value}
focused={selectedItemId === option.value}
onClick={() => handleSelect(option.value)}
/>
</SelectableListItem>
))}
</>
)}
{shouldShowSeparator && <DropdownMenuSeparator />}
{shouldShowMetadata && (
<>
<DropdownMenuSectionLabel label={t`Metadata`} />
{filteredMetadataOptions.map((option) => (
<SelectableListItem
key={option.value}
itemId={option.value}
onEnter={() => handleSelect(option.value)}
>
<MenuItemSelect
LeftIcon={option.icon}
text={option.label}
selected={value === option.value}
focused={selectedItemId === option.value}
onClick={() => handleSelect(option.value)}
/>
</SelectableListItem>
))}
</>
)}
</DropdownMenuItemsContainer>
</SelectableList>
</DropdownContent>
}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { DELETE_WEBHOOK } from '@/settings/developers/graphql/mutations/deleteWe
import { UPDATE_WEBHOOK } from '@/settings/developers/graphql/mutations/updateWebhook';
import { GET_WEBHOOK } from '@/settings/developers/graphql/queries/getWebhook';
import { useWebhookForm } from '@/settings/developers/hooks/useWebhookForm';
import { WEBHOOK_EMPTY_OPERATION } from '~/pages/settings/developers/webhooks/constants/WebhookEmptyOperation';

const mockNavigateSettings = jest.fn();
const mockEnqueueSuccessSnackBar = jest.fn();
Expand Down Expand Up @@ -146,7 +147,7 @@ describe('useWebhookForm', () => {
expect(result.current.formConfig.getValues()).toEqual({
targetUrl: '',
description: '',
operations: [{ object: '*', action: '*' }],
operations: [{ object: '*', action: '*' }, WEBHOOK_EMPTY_OPERATION],
secret: '',
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type UseWebhookFormProps = {
const DEFAULT_FORM_VALUES: WebhookFormValues = {
targetUrl: '',
description: '',
operations: [{ object: '*', action: '*' }],
operations: addEmptyOperationIfNecessary([{ object: '*', action: '*' }]),
secret: '',
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('addEmptyOperationIfNecessary', () => {
]);
});

it('should not add empty operation when wildcard operation exists', () => {
it('should add empty operation when only record wildcard operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
Expand All @@ -29,6 +29,36 @@ describe('addEmptyOperationIfNecessary', () => {
expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'person', action: 'created' },
WEBHOOK_EMPTY_OPERATION,
]);
});

it('should add empty operation when only metadata wildcard operation exists', () => {
const operations: WebhookOperationType[] = [
{ object: 'metadata.*', action: '*' },
{ object: 'person', action: 'created' },
];

const result = addEmptyOperationIfNecessary(operations);

expect(result).toEqual([
{ object: 'metadata.*', action: '*' },
{ object: 'person', action: 'created' },
WEBHOOK_EMPTY_OPERATION,
]);
});

it('should not add empty operation when both record and metadata wildcard operations exist', () => {
const operations: WebhookOperationType[] = [
{ object: '*', action: '*' },
{ object: 'metadata.*', action: '*' },
];

const result = addEmptyOperationIfNecessary(operations);

expect(result).toEqual([
{ object: '*', action: '*' },
{ object: 'metadata.*', action: '*' },
]);
});

Expand Down
Loading