Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0091792d78 | |||
| eb46ca4355 | |||
| a9f8a7e1fa | |||
| 5526d2e5d0 | |||
| 9d289b5e10 |
@@ -3687,21 +3687,6 @@ export type ObjectStandardOverrides = {
|
||||
translations?: Maybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type OnDbEvent = {
|
||||
__typename?: 'OnDbEvent';
|
||||
action: DatabaseEventAction;
|
||||
eventDate: Scalars['DateTime'];
|
||||
objectNameSingular: Scalars['String'];
|
||||
record: Scalars['JSON'];
|
||||
updatedFields?: Maybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type OnDbEventInput = {
|
||||
action?: InputMaybe<DatabaseEventAction>;
|
||||
objectNameSingular?: InputMaybe<Scalars['String']>;
|
||||
recordId?: InputMaybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
/** Onboarding status */
|
||||
export enum OnboardingStatus {
|
||||
BOOK_ONBOARDING = 'BOOK_ONBOARDING',
|
||||
@@ -4738,7 +4723,6 @@ export type StandardOverrides = {
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
logicFunctionLogs: LogicFunctionLogs;
|
||||
onDbEvent: OnDbEvent;
|
||||
onEventSubscription?: Maybe<EventSubscription>;
|
||||
};
|
||||
|
||||
@@ -4748,11 +4732,6 @@ export type SubscriptionLogicFunctionLogsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionOnDbEventArgs = {
|
||||
input: OnDbEventInput;
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionOnEventSubscriptionArgs = {
|
||||
eventStreamId: Scalars['String'];
|
||||
};
|
||||
|
||||
@@ -58,15 +58,17 @@ export const computeContextStoreFilters = ({
|
||||
]);
|
||||
}
|
||||
if (contextStoreTargetedRecordsRule.mode === 'selection') {
|
||||
if (contextStoreTargetedRecordsRule.selectedRecordIds.length === 0) {
|
||||
return { id: { in: [] } };
|
||||
}
|
||||
|
||||
queryFilter = makeAndFilterVariables([
|
||||
recordGqlFilterForAnyFieldFilter,
|
||||
contextStoreTargetedRecordsRule.selectedRecordIds.length > 0
|
||||
? {
|
||||
id: {
|
||||
in: contextStoreTargetedRecordsRule.selectedRecordIds,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
{
|
||||
id: {
|
||||
in: contextStoreTargetedRecordsRule.selectedRecordIds,
|
||||
},
|
||||
},
|
||||
computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
fields: objectMetadataItem?.fields ?? [],
|
||||
|
||||
+15
@@ -1,6 +1,7 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
|
||||
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
@@ -15,6 +16,9 @@ jest.mock('@/object-record/hooks/useRefetchAggregateQueries', () => ({
|
||||
refetchAggregateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@/object-record/hooks/useRefetchFindManyRecords', () => ({
|
||||
useRefetchFindManyRecords: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/object-record/hooks/useIncrementalFetchAndMutateRecords');
|
||||
|
||||
const mockUseObjectMetadataItem = jest.mocked(useObjectMetadataItem);
|
||||
@@ -25,6 +29,7 @@ const mockUseUpdateManyRecords = jest.mocked(useUpdateManyRecords);
|
||||
const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
|
||||
useIncrementalFetchAndMutateRecords,
|
||||
);
|
||||
const mockUseRefetchFindManyRecords = jest.mocked(useRefetchFindManyRecords);
|
||||
|
||||
describe('useIncrementalUpdateManyRecords', () => {
|
||||
const mockUpdateManyRecords = jest.fn();
|
||||
@@ -53,6 +58,11 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
updateProgress: mockUpdateProgress,
|
||||
cancel: jest.fn(),
|
||||
});
|
||||
|
||||
const mockRefetchFindManyRecords = jest.fn();
|
||||
mockUseRefetchFindManyRecords.mockReturnValue({
|
||||
refetchFindManyRecords: mockRefetchFindManyRecords,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call incrementalFetchAndMutate and execute mutations via useUpdateManyRecords', async () => {
|
||||
@@ -81,6 +91,7 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
delayInMsBetweenRequests: 50,
|
||||
skipRegisterObjectOperation: true,
|
||||
skipRefetchAggregateQueries: true,
|
||||
skipOptimisticEffect: true,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockUpdateProgress).toHaveBeenCalledWith(2, 2);
|
||||
@@ -102,6 +113,10 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { refetchFindManyRecords } =
|
||||
mockUseRefetchFindManyRecords.mock.results[0].value;
|
||||
expect(refetchFindManyRecords).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass abortSignal to updateManyRecords', async () => {
|
||||
|
||||
+8
-1
@@ -1,11 +1,12 @@
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
|
||||
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
|
||||
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
|
||||
@@ -39,6 +40,10 @@ export const useIncrementalUpdateManyRecords = <
|
||||
|
||||
const { refetchAggregateQueries } = useRefetchAggregateQueries();
|
||||
|
||||
const { refetchFindManyRecords } = useRefetchFindManyRecords({
|
||||
objectMetadataNamePlural: objectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
const {
|
||||
incrementalFetchAndMutate,
|
||||
progress,
|
||||
@@ -68,6 +73,7 @@ export const useIncrementalUpdateManyRecords = <
|
||||
delayInMsBetweenRequests: delayInMsBetweenMutations,
|
||||
skipRegisterObjectOperation: true,
|
||||
skipRefetchAggregateQueries: true,
|
||||
skipOptimisticEffect: true,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
@@ -78,6 +84,7 @@ export const useIncrementalUpdateManyRecords = <
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await refetchFindManyRecords();
|
||||
await refetchAggregateQueries({
|
||||
objectMetadataNamePlural: objectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
export const useRefetchFindManyRecords = ({
|
||||
objectMetadataNamePlural,
|
||||
}: {
|
||||
objectMetadataNamePlural: string;
|
||||
}) => {
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const refetchFindManyRecords = async () => {
|
||||
const findManyRecordsQueryName = `FindMany${capitalize(
|
||||
objectMetadataNamePlural,
|
||||
)}`;
|
||||
|
||||
await apolloCoreClient.refetchQueries({
|
||||
include: [findManyRecordsQueryName],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
refetchFindManyRecords,
|
||||
};
|
||||
};
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
|
||||
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
|
||||
import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId';
|
||||
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { useActiveRecordBoardCard } from '@/object-record/record-board/hooks/useActiveRecordBoardCard';
|
||||
import { useFocusedRecordBoardCard } from '@/object-record/record-board/hooks/useFocusedRecordBoardCard';
|
||||
@@ -37,6 +38,7 @@ export const RecordBoardClickOutsideEffect = () => {
|
||||
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
|
||||
RECORD_BOARD_CARD_CLICK_OUTSIDE_ID,
|
||||
LINK_CHIP_CLICK_OUTSIDE_ID,
|
||||
SIDE_PANEL_CLICK_OUTSIDE_ID,
|
||||
],
|
||||
listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID,
|
||||
refs: [],
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId';
|
||||
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
|
||||
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
|
||||
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
|
||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||
import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/useLeaveTableFocus';
|
||||
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
|
||||
@@ -30,6 +31,7 @@ export const RecordTableBodyFocusClickOutsideEffect = ({
|
||||
COMMAND_MENU_CLICK_OUTSIDE_ID,
|
||||
PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID,
|
||||
MODAL_BACKDROP_CLICK_OUTSIDE_ID,
|
||||
SIDE_PANEL_CLICK_OUTSIDE_ID,
|
||||
],
|
||||
listenerId: RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID,
|
||||
refs: [tableBodyRef],
|
||||
|
||||
+33
-11
@@ -1,15 +1,22 @@
|
||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { UpdateMultipleRecordsFooter } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsFooter';
|
||||
import { UpdateMultipleRecordsForm } from '@/object-record/record-update-multiple/components/UpdateMultipleRecordsForm';
|
||||
import { useUpdateMultipleRecordsActions } from '@/object-record/record-update-multiple/hooks/useUpdateMultipleRecordsActions';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
|
||||
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID =
|
||||
'update-multiple-records-confirmation';
|
||||
|
||||
const StyledShowPageRightContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -41,23 +48,30 @@ export const UpdateMultipleRecordsContainer = ({
|
||||
contextStoreInstanceId,
|
||||
});
|
||||
|
||||
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
|
||||
contextStoreNumberOfSelectedRecordsComponentState,
|
||||
contextStoreInstanceId,
|
||||
);
|
||||
|
||||
const hasSelectedRecords = contextStoreNumberOfSelectedRecords > 0;
|
||||
|
||||
const { t } = useLingui();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { openModal } = useModal();
|
||||
const { closeSidePanelMenu } = useSidePanelMenu();
|
||||
|
||||
const [fieldUpdates, setFieldUpdates] = useState<UpdateMultipleRecordsState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
const handleUpdateClick = () => {
|
||||
openModal(UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID);
|
||||
};
|
||||
|
||||
const handleConfirmedUpdate = async () => {
|
||||
try {
|
||||
const count = await updateRecords(fieldUpdates);
|
||||
if (count !== undefined) {
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Successfully updated ${count} records`,
|
||||
});
|
||||
closeSidePanelMenu();
|
||||
}
|
||||
await updateRecords(fieldUpdates);
|
||||
closeSidePanelMenu();
|
||||
} catch (error) {
|
||||
enqueueErrorSnackBar({
|
||||
message:
|
||||
@@ -99,12 +113,20 @@ export const UpdateMultipleRecordsContainer = ({
|
||||
<UpdateMultipleRecordsFooter
|
||||
isUpdating={isUpdating}
|
||||
progress={progress}
|
||||
onUpdate={handleUpdate}
|
||||
onUpdate={handleUpdateClick}
|
||||
onCancel={handleCancel}
|
||||
isUpdateDisabled={!hasChanges}
|
||||
isUpdateDisabled={!hasChanges || !hasSelectedRecords}
|
||||
/>
|
||||
</StyledShowPageRightContainer>
|
||||
</ShowPageContainer>
|
||||
<ConfirmationModal
|
||||
modalInstanceId={UPDATE_MULTIPLE_RECORDS_CONFIRMATION_MODAL_ID}
|
||||
title={t`Update ${contextStoreNumberOfSelectedRecords} records`}
|
||||
subtitle={t`This will modify ${contextStoreNumberOfSelectedRecords} records. This action cannot be undone.`}
|
||||
onConfirmClick={handleConfirmedUpdate}
|
||||
confirmButtonText={t`Update records`}
|
||||
confirmButtonAccent="blue"
|
||||
/>
|
||||
</SidePanelProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-6
@@ -62,13 +62,8 @@ export const useUpdateMultipleRecordsActions = ({
|
||||
filter: graphqlFilter,
|
||||
});
|
||||
|
||||
const updateRecords = async (fieldsToUpdate: Record<string, any>) => {
|
||||
const count = await incrementalUpdateManyRecords(fieldsToUpdate);
|
||||
return count;
|
||||
};
|
||||
|
||||
return {
|
||||
updateRecords,
|
||||
updateRecords: incrementalUpdateManyRecords,
|
||||
isUpdating,
|
||||
progress,
|
||||
cancel,
|
||||
|
||||
+2
-4
@@ -40,8 +40,7 @@ export const SettingsNavigationDrawerItem = ({
|
||||
to={href}
|
||||
Icon={item.Icon}
|
||||
active={isActive}
|
||||
soon={item.soon}
|
||||
isNew={item.isNew}
|
||||
modifier={item.modifier}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
</AdvancedSettingsWrapper>
|
||||
@@ -56,8 +55,7 @@ export const SettingsNavigationDrawerItem = ({
|
||||
to={href || undefined}
|
||||
Icon={item.Icon}
|
||||
active={isActive}
|
||||
soon={item.soon}
|
||||
isNew={item.isNew}
|
||||
modifier={item.modifier}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,10 @@ import { billingState } from '@/client-config/states/billingState';
|
||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
import { usePermissionFlagMap } from '@/settings/roles/hooks/usePermissionFlagMap';
|
||||
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
|
||||
import { type NavigationDrawerItemIndentationLevel } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
import {
|
||||
type NavigationDrawerItemIndentationLevel,
|
||||
type NavigationDrawerItemModifier,
|
||||
} from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -57,8 +60,7 @@ export type SettingsNavigationItem = {
|
||||
isHidden?: boolean;
|
||||
subItems?: SettingsNavigationItem[];
|
||||
isAdvanced?: boolean;
|
||||
soon?: boolean;
|
||||
isNew?: boolean;
|
||||
modifier?: NavigationDrawerItemModifier;
|
||||
};
|
||||
|
||||
const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
@@ -179,7 +181,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
isHidden:
|
||||
!isApplicationEnabled ||
|
||||
!permissionMap[PermissionFlagType.WORKSPACE],
|
||||
isNew: true,
|
||||
modifier: 'new',
|
||||
},
|
||||
{
|
||||
label: t`AI`,
|
||||
@@ -187,7 +189,7 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
|
||||
Icon: IconSparkles,
|
||||
isHidden:
|
||||
!isAIEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
|
||||
isNew: true,
|
||||
modifier: 'new',
|
||||
},
|
||||
{
|
||||
label: t`Security`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
|
||||
import { SidePanelWidthEffect } from '@/side-panel/components/SidePanelWidthEffect';
|
||||
import { SIDE_PANEL_CLICK_OUTSIDE_ID } from '@/side-panel/constants/SidePanelClickOutsideId';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { useSidePanelCloseAnimationCompleteCleanup } from '@/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup';
|
||||
import {
|
||||
@@ -10,6 +11,7 @@ import { isSidePanelClosingState } from '@/side-panel/states/isSidePanelClosingS
|
||||
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
|
||||
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
|
||||
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
|
||||
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
|
||||
import { ResizablePanelGap } from '@/ui/layout/resizable-panel/components/ResizablePanelGap';
|
||||
import { SIDE_PANEL_CONSTRAINTS } from '@/side-panel/constants/SidePanelConstraints';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
@@ -138,11 +140,16 @@ export const SidePanelForDesktop = () => {
|
||||
isResizing={isResizing}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
data-side-panel=""
|
||||
data-click-outside-id={SIDE_PANEL_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
<StyledSidePanel>
|
||||
<StyledModalContainer ref={handleModalContainerRef} />
|
||||
<ModalContainerContext.Provider value={{ container: modalContainer }}>
|
||||
{shouldShowContent && <SidePanelRouter />}
|
||||
<ParentClickOutsideIdContext.Provider
|
||||
value={SIDE_PANEL_CLICK_OUTSIDE_ID}
|
||||
>
|
||||
{shouldShowContent && <SidePanelRouter />}
|
||||
</ParentClickOutsideIdContext.Provider>
|
||||
</ModalContainerContext.Provider>
|
||||
</StyledSidePanel>
|
||||
</StyledSidePanelWrapper>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SIDE_PANEL_CLICK_OUTSIDE_ID = 'side-panel';
|
||||
+1
-1
@@ -45,7 +45,7 @@ export const SignInAppNavigationDrawerMock = ({
|
||||
label={t`Search`}
|
||||
Icon={IconSearch}
|
||||
onClick={() => {}}
|
||||
keyboard={[getOsControlSymbol(), 'K']}
|
||||
modifier={{ keyboard: [getOsControlSymbol(), 'K'] }}
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
label={t`Settings`}
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContaine
|
||||
import { HotkeyEffect } from '@/ui/utilities/hotkey/components/HotkeyEffect';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import { ParentClickOutsideIdContext } from '@/ui/utilities/pointer-event/contexts/ParentClickOutsideIdContext';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -141,6 +142,7 @@ export const DropdownInternalContainer = ({
|
||||
};
|
||||
|
||||
const { excludedClickOutsideId } = useContext(ClickOutsideListenerContext);
|
||||
const parentClickOutsideId = useContext(ParentClickOutsideIdContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -162,7 +164,11 @@ export const DropdownInternalContainer = ({
|
||||
isDropdownInModal={isDropdownInModal}
|
||||
>
|
||||
<OverlayContainer>
|
||||
<StyledDropdownInsideContainer id={dropdownId} data-select-disable>
|
||||
<StyledDropdownInsideContainer
|
||||
id={dropdownId}
|
||||
data-select-disable
|
||||
data-click-outside-id={parentClickOutsideId}
|
||||
>
|
||||
{dropdownComponents}
|
||||
</StyledDropdownInsideContainer>
|
||||
</OverlayContainer>
|
||||
|
||||
+53
-74
@@ -37,6 +37,11 @@ const DEFAULT_INDENTATION_LEVEL = 1;
|
||||
|
||||
export type NavigationDrawerItemIndentationLevel = 1 | 2;
|
||||
|
||||
export type NavigationDrawerItemModifier =
|
||||
| 'soon'
|
||||
| 'new'
|
||||
| { keyboard: string[] };
|
||||
|
||||
export type NavigationDrawerItemProps = {
|
||||
className?: string;
|
||||
label: string;
|
||||
@@ -48,17 +53,12 @@ export type NavigationDrawerItemProps = {
|
||||
Icon?: IconComponent | ((props: TablerIconsProps) => JSX.Element);
|
||||
iconColor?: string | null;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
soon?: boolean;
|
||||
isNew?: boolean;
|
||||
count?: number;
|
||||
keyboard?: string[];
|
||||
modifier?: NavigationDrawerItemModifier;
|
||||
rightOptions?: ReactNode;
|
||||
alwaysShowRightOptions?: boolean;
|
||||
isDragging?: boolean;
|
||||
isRightOptionsDropdownOpen?: boolean;
|
||||
triggerEvent?: TriggerEventType;
|
||||
mouseUpNavigation?: boolean;
|
||||
preventCollapseOnMobile?: boolean;
|
||||
isSelectedInEditMode?: boolean;
|
||||
variant?: 'default' | 'tertiary';
|
||||
@@ -67,14 +67,13 @@ export type NavigationDrawerItemProps = {
|
||||
type StyledItemProps = Pick<
|
||||
NavigationDrawerItemProps,
|
||||
| 'active'
|
||||
| 'danger'
|
||||
| 'indentationLevel'
|
||||
| 'soon'
|
||||
| 'to'
|
||||
| 'isDragging'
|
||||
| 'isSelectedInEditMode'
|
||||
| 'variant'
|
||||
> & {
|
||||
isSoon: boolean;
|
||||
isNavigationDrawerExpanded: boolean;
|
||||
hasRightOptions: boolean;
|
||||
href?: string;
|
||||
@@ -92,14 +91,11 @@ const StyledItem = styled.button<StyledItemProps>`
|
||||
: '1px solid transparent'};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-sizing: border-box;
|
||||
color: ${({ active, danger, soon, variant }) => {
|
||||
color: ${({ active, isSoon, variant }) => {
|
||||
if (active === true) {
|
||||
return themeCssVariables.font.color.primary;
|
||||
}
|
||||
if (danger === true) {
|
||||
return themeCssVariables.color.red;
|
||||
}
|
||||
if (soon === true) {
|
||||
if (isSoon) {
|
||||
return themeCssVariables.font.color.light;
|
||||
}
|
||||
if (variant === 'tertiary') {
|
||||
@@ -107,8 +103,8 @@ const StyledItem = styled.button<StyledItemProps>`
|
||||
}
|
||||
return themeCssVariables.font.color.secondary;
|
||||
}};
|
||||
cursor: ${({ soon, isDragging }) =>
|
||||
isDragging ? 'grabbing' : soon ? 'default' : 'pointer'};
|
||||
cursor: ${({ isSoon, isDragging }) =>
|
||||
isDragging ? 'grabbing' : isSoon ? 'default' : 'pointer'};
|
||||
display: flex;
|
||||
font-family: ${themeCssVariables.font.family};
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
@@ -122,7 +118,7 @@ const StyledItem = styled.button<StyledItemProps>`
|
||||
? themeCssVariables.spacing['0.5']
|
||||
: themeCssVariables.spacing[1]};
|
||||
padding-top: ${themeCssVariables.spacing[1]};
|
||||
pointer-events: ${({ soon }) => (soon ? 'none' : 'auto')};
|
||||
pointer-events: ${({ isSoon }) => (isSoon ? 'none' : 'auto')};
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
width: ${({ isNavigationDrawerExpanded, hasRightOptions }) =>
|
||||
@@ -132,10 +128,7 @@ const StyledItem = styled.button<StyledItemProps>`
|
||||
|
||||
&:hover {
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
color: ${({ danger }) =>
|
||||
danger
|
||||
? themeCssVariables.color.red
|
||||
: themeCssVariables.font.color.primary};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
|
||||
&:hover .keyboard-shortcuts {
|
||||
@@ -172,20 +165,6 @@ const StyledItemSecondaryLabel = styled.span`
|
||||
font-weight: ${themeCssVariables.font.weight.regular};
|
||||
`;
|
||||
|
||||
const StyledItemCount = styled.span`
|
||||
align-items: center;
|
||||
background-color: ${themeCssVariables.color.blue};
|
||||
border-radius: ${themeCssVariables.border.radius.rounded};
|
||||
color: ${themeCssVariables.grayScale.gray1};
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const StyledKeyBoardShortcut = styled.span`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.transparent.lighter};
|
||||
@@ -277,18 +256,13 @@ export const NavigationDrawerItem = ({
|
||||
to,
|
||||
onClick,
|
||||
active,
|
||||
danger,
|
||||
soon,
|
||||
isNew,
|
||||
count,
|
||||
keyboard,
|
||||
modifier,
|
||||
subItemState,
|
||||
rightOptions,
|
||||
alwaysShowRightOptions = false,
|
||||
isDragging,
|
||||
isRightOptionsDropdownOpen,
|
||||
triggerEvent,
|
||||
mouseUpNavigation = false,
|
||||
preventCollapseOnMobile = false,
|
||||
isSelectedInEditMode = false,
|
||||
variant = 'default',
|
||||
@@ -301,10 +275,15 @@ export const NavigationDrawerItem = ({
|
||||
|
||||
const { navigationItemId } = useNavigationDrawerTooltip(label, to);
|
||||
|
||||
const isSoon = modifier === 'soon';
|
||||
const isNew = modifier === 'new';
|
||||
const keyboardKeys =
|
||||
isDefined(modifier) && typeof modifier === 'object'
|
||||
? modifier.keyboard
|
||||
: undefined;
|
||||
|
||||
const showBreadcrumb = indentationLevel === 2;
|
||||
const showStyledSpacer = Boolean(
|
||||
soon || isNew || count || keyboard || rightOptions,
|
||||
);
|
||||
const showStyledSpacer = isDefined(modifier) || isDefined(rightOptions);
|
||||
|
||||
const handleMobileNavigation = () => {
|
||||
if (isMobile && !preventCollapseOnMobile) {
|
||||
@@ -314,6 +293,7 @@ export const NavigationDrawerItem = ({
|
||||
|
||||
const isExternalLink =
|
||||
isDefined(to) && (to.startsWith('http://') || to.startsWith('https://'));
|
||||
const isInternalLink = isDefined(to) && !isExternalLink;
|
||||
|
||||
const handleExternalLinkClick = () => {
|
||||
handleMobileNavigation();
|
||||
@@ -332,40 +312,37 @@ export const NavigationDrawerItem = ({
|
||||
triggerEvent,
|
||||
});
|
||||
|
||||
const elementType = isExternalLink
|
||||
? 'a'
|
||||
: isInternalLink
|
||||
? Link
|
||||
: isDefined(rightOptions)
|
||||
? 'div'
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<StyledNavigationDrawerItemContainer>
|
||||
<StyledItem
|
||||
id={navigationItemId}
|
||||
className={`navigation-drawer-item ${className || ''}`}
|
||||
onClick={
|
||||
mouseUpNavigation ? onClick : handleMouseDownNavigationClickClick
|
||||
}
|
||||
onMouseDown={mouseUpNavigation ? undefined : handleMouseDown}
|
||||
onClick={handleMouseDownNavigationClickClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
active={active}
|
||||
aria-selected={active}
|
||||
danger={danger}
|
||||
soon={soon}
|
||||
isSoon={isSoon}
|
||||
variant={variant}
|
||||
as={
|
||||
to
|
||||
? isExternalLink
|
||||
? 'a'
|
||||
: Link
|
||||
: isDefined(rightOptions)
|
||||
? 'div'
|
||||
: undefined
|
||||
}
|
||||
role={to ? undefined : isDefined(rightOptions) ? 'button' : undefined}
|
||||
to={isExternalLink ? undefined : to}
|
||||
href={isExternalLink ? to : undefined}
|
||||
target={isExternalLink ? '_blank' : undefined}
|
||||
rel={isExternalLink ? 'noopener noreferrer' : undefined}
|
||||
draggable={to && !isExternalLink ? false : undefined}
|
||||
indentationLevel={indentationLevel}
|
||||
isNavigationDrawerExpanded={isNavigationDrawerExpanded}
|
||||
isDragging={isDragging}
|
||||
hasRightOptions={isDefined(rightOptions)}
|
||||
isSelectedInEditMode={isSelectedInEditMode}
|
||||
as={elementType}
|
||||
role={!to && isDefined(rightOptions) ? 'button' : undefined}
|
||||
to={isInternalLink ? to : undefined}
|
||||
href={isExternalLink ? to : undefined}
|
||||
target={isExternalLink ? '_blank' : undefined}
|
||||
rel={isExternalLink ? 'noopener noreferrer' : undefined}
|
||||
draggable={isInternalLink ? false : undefined}
|
||||
>
|
||||
<StyledItemElementsContainer>
|
||||
{showBreadcrumb && (
|
||||
@@ -419,7 +396,7 @@ export const NavigationDrawerItem = ({
|
||||
|
||||
{showStyledSpacer && <StyledSpacer />}
|
||||
|
||||
{soon && (
|
||||
{isSoon && (
|
||||
<NavigationDrawerAnimatedCollapseWrapper>
|
||||
<Pill label={t`Soon`} />
|
||||
</NavigationDrawerAnimatedCollapseWrapper>
|
||||
@@ -431,23 +408,25 @@ export const NavigationDrawerItem = ({
|
||||
</NavigationDrawerAnimatedCollapseWrapper>
|
||||
)}
|
||||
|
||||
{!!count && (
|
||||
<NavigationDrawerAnimatedCollapseWrapper>
|
||||
<StyledItemCount>{count}</StyledItemCount>
|
||||
</NavigationDrawerAnimatedCollapseWrapper>
|
||||
)}
|
||||
|
||||
{keyboard && (
|
||||
{isDefined(keyboardKeys) && (
|
||||
<NavigationDrawerAnimatedCollapseWrapper>
|
||||
<StyledKeyBoardShortcut className="keyboard-shortcuts">
|
||||
<Label>{keyboard}</Label>
|
||||
<Label>{keyboardKeys}</Label>
|
||||
</StyledKeyBoardShortcut>
|
||||
</NavigationDrawerAnimatedCollapseWrapper>
|
||||
)}
|
||||
|
||||
{isDefined(rightOptions) && (
|
||||
<NavigationDrawerAnimatedCollapseWrapper>
|
||||
<StyledRightOptionsContainer>
|
||||
{/* When StyledItem renders as a Link, we need both handlers to
|
||||
prevent navigation when interacting with rightOptions:
|
||||
- onMouseDown: stops useMouseDownNavigation from calling navigate()
|
||||
- onClickCapture: prevents the native <a> follow since the child's
|
||||
stopPropagation blocks Link's own preventDefault */}
|
||||
<StyledRightOptionsContainer
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClickCapture={(e) => e.preventDefault()}
|
||||
>
|
||||
<StyledRightOptionsVisbility
|
||||
data-visible={
|
||||
isMobile ||
|
||||
|
||||
+2
-8
@@ -14,10 +14,7 @@ export const NavigationDrawerSubItem = ({
|
||||
to,
|
||||
onClick,
|
||||
active,
|
||||
danger,
|
||||
soon,
|
||||
count,
|
||||
keyboard,
|
||||
modifier,
|
||||
subItemState,
|
||||
rightOptions,
|
||||
isDragging,
|
||||
@@ -37,10 +34,7 @@ export const NavigationDrawerSubItem = ({
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
active={active}
|
||||
danger={danger}
|
||||
soon={soon}
|
||||
count={count}
|
||||
keyboard={keyboard}
|
||||
modifier={modifier}
|
||||
rightOptions={rightOptions}
|
||||
isDragging={isDragging}
|
||||
isSelectedInEditMode={isSelectedInEditMode}
|
||||
|
||||
+3
-8
@@ -86,24 +86,19 @@ export const Default: Story = {
|
||||
label="Notifications"
|
||||
to="/inbox"
|
||||
Icon={IconBell}
|
||||
soon={true}
|
||||
modifier="soon"
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
label="Search"
|
||||
Icon={IconSearch}
|
||||
keyboard={[`${getOsControlSymbol()}`, 'K']}
|
||||
modifier={{ keyboard: [`${getOsControlSymbol()}`, 'K'] }}
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
label="Settings"
|
||||
to="/settings/profile"
|
||||
Icon={IconSettings}
|
||||
/>
|
||||
<NavigationDrawerItem
|
||||
label="Tasks"
|
||||
to="/tasks"
|
||||
Icon={IconCheckbox}
|
||||
count={2}
|
||||
/>
|
||||
<NavigationDrawerItem label="Tasks" to="/tasks" Icon={IconCheckbox} />
|
||||
</NavigationDrawerSection>
|
||||
|
||||
<NavigationDrawerSection>
|
||||
|
||||
+15
-34
@@ -105,23 +105,14 @@ export const NewPill: Story = {
|
||||
args={{
|
||||
label: 'New Feature',
|
||||
Icon: IconSearch,
|
||||
isNew: true,
|
||||
modifier: 'new',
|
||||
}}
|
||||
/>
|
||||
<Story
|
||||
args={{
|
||||
label: 'New Feature with Count',
|
||||
label: 'Feature with Keyboard Shortcut',
|
||||
Icon: IconSearch,
|
||||
isNew: true,
|
||||
count: 5,
|
||||
}}
|
||||
/>
|
||||
<Story
|
||||
args={{
|
||||
label: 'New Feature with Keyboard Shortcut',
|
||||
Icon: IconSearch,
|
||||
isNew: true,
|
||||
keyboard: [getOsControlSymbol(), 'N'],
|
||||
modifier: { keyboard: [getOsControlSymbol(), 'N'] },
|
||||
}}
|
||||
/>
|
||||
</StyledContainer>
|
||||
@@ -195,12 +186,6 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
|
||||
pseudo: { hover: ['.hover'] },
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'danger',
|
||||
values: [true, false],
|
||||
props: (danger: boolean) => ({ danger }),
|
||||
labels: (danger: boolean) => (danger ? 'Danger' : 'No Danger'),
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
values: [true, false],
|
||||
@@ -216,23 +201,19 @@ export const Catalog: CatalogStory<Story, typeof NavigationDrawerItem> = {
|
||||
},
|
||||
{
|
||||
name: 'adornments',
|
||||
values: [
|
||||
'Without Adornments',
|
||||
'Soon Pill',
|
||||
'New Pill',
|
||||
'Count',
|
||||
'Keyboard Keys',
|
||||
],
|
||||
values: ['Without Modifier', 'Soon', 'New', 'Keyboard Keys'],
|
||||
props: (adornmentName: string) =>
|
||||
adornmentName === 'Soon Pill'
|
||||
? { soon: true }
|
||||
: adornmentName === 'New Pill'
|
||||
? { isNew: true }
|
||||
: adornmentName === 'Count'
|
||||
? { count: 3 }
|
||||
: adornmentName === 'Keyboard Keys'
|
||||
? { keyboard: [getOsControlSymbol(), 'K'] }
|
||||
: {},
|
||||
adornmentName === 'Soon'
|
||||
? { modifier: 'soon' }
|
||||
: adornmentName === 'New'
|
||||
? { modifier: 'new' }
|
||||
: adornmentName === 'Keyboard Keys'
|
||||
? {
|
||||
modifier: {
|
||||
keyboard: [getOsControlSymbol(), 'K'],
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ParentClickOutsideIdContext = createContext<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -1236,107 +1236,6 @@ enum PageLayoutType {
|
||||
DASHBOARD
|
||||
}
|
||||
|
||||
type FileWithSignedUrl {
|
||||
id: UUID!
|
||||
path: String!
|
||||
size: Float!
|
||||
createdAt: DateTime!
|
||||
url: String!
|
||||
}
|
||||
|
||||
type RecordIdentifier {
|
||||
id: UUID!
|
||||
labelIdentifier: String!
|
||||
imageIdentifier: String
|
||||
}
|
||||
|
||||
type NavigationMenuItem {
|
||||
id: UUID!
|
||||
userWorkspaceId: UUID
|
||||
targetRecordId: UUID
|
||||
targetObjectMetadataId: UUID
|
||||
viewId: UUID
|
||||
type: NavigationMenuItemType!
|
||||
name: String
|
||||
link: String
|
||||
icon: String
|
||||
color: String
|
||||
folderId: UUID
|
||||
position: Float!
|
||||
applicationId: UUID
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
targetRecordIdentifier: RecordIdentifier
|
||||
}
|
||||
|
||||
enum NavigationMenuItemType {
|
||||
VIEW
|
||||
FOLDER
|
||||
LINK
|
||||
OBJECT
|
||||
RECORD
|
||||
}
|
||||
|
||||
type ObjectRecordEventProperties {
|
||||
updatedFields: [String!]
|
||||
before: JSON
|
||||
after: JSON
|
||||
diff: JSON
|
||||
}
|
||||
|
||||
type MetadataEvent {
|
||||
type: MetadataEventAction!
|
||||
metadataName: String!
|
||||
recordId: String!
|
||||
properties: ObjectRecordEventProperties!
|
||||
updatedCollectionHash: String
|
||||
}
|
||||
|
||||
"""Metadata Event Action"""
|
||||
enum MetadataEventAction {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
}
|
||||
|
||||
type ObjectRecordEvent {
|
||||
action: DatabaseEventAction!
|
||||
objectNameSingular: String!
|
||||
recordId: String!
|
||||
userId: String
|
||||
workspaceMemberId: String
|
||||
properties: ObjectRecordEventProperties!
|
||||
}
|
||||
|
||||
"""Database Event Action"""
|
||||
enum DatabaseEventAction {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
DESTROYED
|
||||
RESTORED
|
||||
UPSERTED
|
||||
}
|
||||
|
||||
type ObjectRecordEventWithQueryIds {
|
||||
queryIds: [String!]!
|
||||
objectRecordEvent: ObjectRecordEvent!
|
||||
}
|
||||
|
||||
type EventSubscription {
|
||||
eventStreamId: String!
|
||||
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
|
||||
metadataEvents: [MetadataEvent!]!
|
||||
}
|
||||
|
||||
type OnDbEvent {
|
||||
action: DatabaseEventAction!
|
||||
objectNameSingular: String!
|
||||
eventDate: DateTime!
|
||||
record: JSON!
|
||||
updatedFields: [String!]
|
||||
}
|
||||
|
||||
type Analytics {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
@@ -1523,6 +1422,14 @@ type ApprovedAccessDomain {
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type FileWithSignedUrl {
|
||||
id: UUID!
|
||||
path: String!
|
||||
size: Float!
|
||||
createdAt: DateTime!
|
||||
url: String!
|
||||
}
|
||||
|
||||
type WorkspaceInvitation {
|
||||
id: UUID!
|
||||
email: String!
|
||||
@@ -1958,6 +1865,91 @@ type WorkspaceInviteHashValid {
|
||||
isValid: Boolean!
|
||||
}
|
||||
|
||||
type RecordIdentifier {
|
||||
id: UUID!
|
||||
labelIdentifier: String!
|
||||
imageIdentifier: String
|
||||
}
|
||||
|
||||
type NavigationMenuItem {
|
||||
id: UUID!
|
||||
userWorkspaceId: UUID
|
||||
targetRecordId: UUID
|
||||
targetObjectMetadataId: UUID
|
||||
viewId: UUID
|
||||
type: NavigationMenuItemType!
|
||||
name: String
|
||||
link: String
|
||||
icon: String
|
||||
color: String
|
||||
folderId: UUID
|
||||
position: Float!
|
||||
applicationId: UUID
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
targetRecordIdentifier: RecordIdentifier
|
||||
}
|
||||
|
||||
enum NavigationMenuItemType {
|
||||
VIEW
|
||||
FOLDER
|
||||
LINK
|
||||
OBJECT
|
||||
RECORD
|
||||
}
|
||||
|
||||
type ObjectRecordEventProperties {
|
||||
updatedFields: [String!]
|
||||
before: JSON
|
||||
after: JSON
|
||||
diff: JSON
|
||||
}
|
||||
|
||||
type MetadataEvent {
|
||||
type: MetadataEventAction!
|
||||
metadataName: String!
|
||||
recordId: String!
|
||||
properties: ObjectRecordEventProperties!
|
||||
updatedCollectionHash: String
|
||||
}
|
||||
|
||||
"""Metadata Event Action"""
|
||||
enum MetadataEventAction {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
}
|
||||
|
||||
type ObjectRecordEvent {
|
||||
action: DatabaseEventAction!
|
||||
objectNameSingular: String!
|
||||
recordId: String!
|
||||
userId: String
|
||||
workspaceMemberId: String
|
||||
properties: ObjectRecordEventProperties!
|
||||
}
|
||||
|
||||
"""Database Event Action"""
|
||||
enum DatabaseEventAction {
|
||||
CREATED
|
||||
UPDATED
|
||||
DELETED
|
||||
DESTROYED
|
||||
RESTORED
|
||||
UPSERTED
|
||||
}
|
||||
|
||||
type ObjectRecordEventWithQueryIds {
|
||||
queryIds: [String!]!
|
||||
objectRecordEvent: ObjectRecordEvent!
|
||||
}
|
||||
|
||||
type EventSubscription {
|
||||
eventStreamId: String!
|
||||
objectRecordEventsWithQueryIds: [ObjectRecordEventWithQueryIds!]!
|
||||
metadataEvents: [MetadataEvent!]!
|
||||
}
|
||||
|
||||
type LogicFunctionExecutionResult {
|
||||
"""Execution result in JSON format"""
|
||||
data: JSON
|
||||
@@ -4316,17 +4308,10 @@ enum FileFolder {
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
onDbEvent(input: OnDbEventInput!): OnDbEvent!
|
||||
onEventSubscription(eventStreamId: String!): EventSubscription
|
||||
logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs!
|
||||
}
|
||||
|
||||
input OnDbEventInput {
|
||||
action: DatabaseEventAction
|
||||
objectNameSingular: String
|
||||
recordId: UUID
|
||||
}
|
||||
|
||||
input LogicFunctionLogsInput {
|
||||
applicationId: UUID
|
||||
applicationUniversalIdentifier: UUID
|
||||
|
||||
@@ -963,101 +963,6 @@ export interface PageLayout {
|
||||
|
||||
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
|
||||
|
||||
export interface FileWithSignedUrl {
|
||||
id: Scalars['UUID']
|
||||
path: Scalars['String']
|
||||
size: Scalars['Float']
|
||||
createdAt: Scalars['DateTime']
|
||||
url: Scalars['String']
|
||||
__typename: 'FileWithSignedUrl'
|
||||
}
|
||||
|
||||
export interface RecordIdentifier {
|
||||
id: Scalars['UUID']
|
||||
labelIdentifier: Scalars['String']
|
||||
imageIdentifier?: Scalars['String']
|
||||
__typename: 'RecordIdentifier'
|
||||
}
|
||||
|
||||
export interface NavigationMenuItem {
|
||||
id: Scalars['UUID']
|
||||
userWorkspaceId?: Scalars['UUID']
|
||||
targetRecordId?: Scalars['UUID']
|
||||
targetObjectMetadataId?: Scalars['UUID']
|
||||
viewId?: Scalars['UUID']
|
||||
type: NavigationMenuItemType
|
||||
name?: Scalars['String']
|
||||
link?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
color?: Scalars['String']
|
||||
folderId?: Scalars['UUID']
|
||||
position: Scalars['Float']
|
||||
applicationId?: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
targetRecordIdentifier?: RecordIdentifier
|
||||
__typename: 'NavigationMenuItem'
|
||||
}
|
||||
|
||||
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
|
||||
|
||||
export interface ObjectRecordEventProperties {
|
||||
updatedFields?: Scalars['String'][]
|
||||
before?: Scalars['JSON']
|
||||
after?: Scalars['JSON']
|
||||
diff?: Scalars['JSON']
|
||||
__typename: 'ObjectRecordEventProperties'
|
||||
}
|
||||
|
||||
export interface MetadataEvent {
|
||||
type: MetadataEventAction
|
||||
metadataName: Scalars['String']
|
||||
recordId: Scalars['String']
|
||||
properties: ObjectRecordEventProperties
|
||||
updatedCollectionHash?: Scalars['String']
|
||||
__typename: 'MetadataEvent'
|
||||
}
|
||||
|
||||
|
||||
/** Metadata Event Action */
|
||||
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
|
||||
|
||||
export interface ObjectRecordEvent {
|
||||
action: DatabaseEventAction
|
||||
objectNameSingular: Scalars['String']
|
||||
recordId: Scalars['String']
|
||||
userId?: Scalars['String']
|
||||
workspaceMemberId?: Scalars['String']
|
||||
properties: ObjectRecordEventProperties
|
||||
__typename: 'ObjectRecordEvent'
|
||||
}
|
||||
|
||||
|
||||
/** Database Event Action */
|
||||
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
|
||||
|
||||
export interface ObjectRecordEventWithQueryIds {
|
||||
queryIds: Scalars['String'][]
|
||||
objectRecordEvent: ObjectRecordEvent
|
||||
__typename: 'ObjectRecordEventWithQueryIds'
|
||||
}
|
||||
|
||||
export interface EventSubscription {
|
||||
eventStreamId: Scalars['String']
|
||||
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
|
||||
metadataEvents: MetadataEvent[]
|
||||
__typename: 'EventSubscription'
|
||||
}
|
||||
|
||||
export interface OnDbEvent {
|
||||
action: DatabaseEventAction
|
||||
objectNameSingular: Scalars['String']
|
||||
eventDate: Scalars['DateTime']
|
||||
record: Scalars['JSON']
|
||||
updatedFields?: Scalars['String'][]
|
||||
__typename: 'OnDbEvent'
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success: Scalars['Boolean']
|
||||
@@ -1241,6 +1146,15 @@ export interface ApprovedAccessDomain {
|
||||
__typename: 'ApprovedAccessDomain'
|
||||
}
|
||||
|
||||
export interface FileWithSignedUrl {
|
||||
id: Scalars['UUID']
|
||||
path: Scalars['String']
|
||||
size: Scalars['Float']
|
||||
createdAt: Scalars['DateTime']
|
||||
url: Scalars['String']
|
||||
__typename: 'FileWithSignedUrl'
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitation {
|
||||
id: Scalars['UUID']
|
||||
email: Scalars['String']
|
||||
@@ -1687,6 +1601,83 @@ export interface WorkspaceInviteHashValid {
|
||||
__typename: 'WorkspaceInviteHashValid'
|
||||
}
|
||||
|
||||
export interface RecordIdentifier {
|
||||
id: Scalars['UUID']
|
||||
labelIdentifier: Scalars['String']
|
||||
imageIdentifier?: Scalars['String']
|
||||
__typename: 'RecordIdentifier'
|
||||
}
|
||||
|
||||
export interface NavigationMenuItem {
|
||||
id: Scalars['UUID']
|
||||
userWorkspaceId?: Scalars['UUID']
|
||||
targetRecordId?: Scalars['UUID']
|
||||
targetObjectMetadataId?: Scalars['UUID']
|
||||
viewId?: Scalars['UUID']
|
||||
type: NavigationMenuItemType
|
||||
name?: Scalars['String']
|
||||
link?: Scalars['String']
|
||||
icon?: Scalars['String']
|
||||
color?: Scalars['String']
|
||||
folderId?: Scalars['UUID']
|
||||
position: Scalars['Float']
|
||||
applicationId?: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
targetRecordIdentifier?: RecordIdentifier
|
||||
__typename: 'NavigationMenuItem'
|
||||
}
|
||||
|
||||
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
|
||||
|
||||
export interface ObjectRecordEventProperties {
|
||||
updatedFields?: Scalars['String'][]
|
||||
before?: Scalars['JSON']
|
||||
after?: Scalars['JSON']
|
||||
diff?: Scalars['JSON']
|
||||
__typename: 'ObjectRecordEventProperties'
|
||||
}
|
||||
|
||||
export interface MetadataEvent {
|
||||
type: MetadataEventAction
|
||||
metadataName: Scalars['String']
|
||||
recordId: Scalars['String']
|
||||
properties: ObjectRecordEventProperties
|
||||
updatedCollectionHash?: Scalars['String']
|
||||
__typename: 'MetadataEvent'
|
||||
}
|
||||
|
||||
|
||||
/** Metadata Event Action */
|
||||
export type MetadataEventAction = 'CREATED' | 'UPDATED' | 'DELETED'
|
||||
|
||||
export interface ObjectRecordEvent {
|
||||
action: DatabaseEventAction
|
||||
objectNameSingular: Scalars['String']
|
||||
recordId: Scalars['String']
|
||||
userId?: Scalars['String']
|
||||
workspaceMemberId?: Scalars['String']
|
||||
properties: ObjectRecordEventProperties
|
||||
__typename: 'ObjectRecordEvent'
|
||||
}
|
||||
|
||||
|
||||
/** Database Event Action */
|
||||
export type DatabaseEventAction = 'CREATED' | 'UPDATED' | 'DELETED' | 'DESTROYED' | 'RESTORED' | 'UPSERTED'
|
||||
|
||||
export interface ObjectRecordEventWithQueryIds {
|
||||
queryIds: Scalars['String'][]
|
||||
objectRecordEvent: ObjectRecordEvent
|
||||
__typename: 'ObjectRecordEventWithQueryIds'
|
||||
}
|
||||
|
||||
export interface EventSubscription {
|
||||
eventStreamId: Scalars['String']
|
||||
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]
|
||||
metadataEvents: MetadataEvent[]
|
||||
__typename: 'EventSubscription'
|
||||
}
|
||||
|
||||
export interface LogicFunctionExecutionResult {
|
||||
/** Execution result in JSON format */
|
||||
data?: Scalars['JSON']
|
||||
@@ -2881,7 +2872,6 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
|
||||
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'AppTarball'
|
||||
|
||||
export interface Subscription {
|
||||
onDbEvent: OnDbEvent
|
||||
onEventSubscription?: EventSubscription
|
||||
logicFunctionLogs: LogicFunctionLogs
|
||||
__typename: 'Subscription'
|
||||
@@ -3897,100 +3887,6 @@ export interface PageLayoutGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface FileWithSignedUrlGenqlSelection{
|
||||
id?: boolean | number
|
||||
path?: boolean | number
|
||||
size?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
url?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface RecordIdentifierGenqlSelection{
|
||||
id?: boolean | number
|
||||
labelIdentifier?: boolean | number
|
||||
imageIdentifier?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface NavigationMenuItemGenqlSelection{
|
||||
id?: boolean | number
|
||||
userWorkspaceId?: boolean | number
|
||||
targetRecordId?: boolean | number
|
||||
targetObjectMetadataId?: boolean | number
|
||||
viewId?: boolean | number
|
||||
type?: boolean | number
|
||||
name?: boolean | number
|
||||
link?: boolean | number
|
||||
icon?: boolean | number
|
||||
color?: boolean | number
|
||||
folderId?: boolean | number
|
||||
position?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
targetRecordIdentifier?: RecordIdentifierGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventPropertiesGenqlSelection{
|
||||
updatedFields?: boolean | number
|
||||
before?: boolean | number
|
||||
after?: boolean | number
|
||||
diff?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MetadataEventGenqlSelection{
|
||||
type?: boolean | number
|
||||
metadataName?: boolean | number
|
||||
recordId?: boolean | number
|
||||
properties?: ObjectRecordEventPropertiesGenqlSelection
|
||||
updatedCollectionHash?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventGenqlSelection{
|
||||
action?: boolean | number
|
||||
objectNameSingular?: boolean | number
|
||||
recordId?: boolean | number
|
||||
userId?: boolean | number
|
||||
workspaceMemberId?: boolean | number
|
||||
properties?: ObjectRecordEventPropertiesGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
|
||||
queryIds?: boolean | number
|
||||
objectRecordEvent?: ObjectRecordEventGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface EventSubscriptionGenqlSelection{
|
||||
eventStreamId?: boolean | number
|
||||
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
|
||||
metadataEvents?: MetadataEventGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface OnDbEventGenqlSelection{
|
||||
action?: boolean | number
|
||||
objectNameSingular?: boolean | number
|
||||
eventDate?: boolean | number
|
||||
record?: boolean | number
|
||||
updatedFields?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface AnalyticsGenqlSelection{
|
||||
/** Boolean that confirms query was dispatched */
|
||||
success?: boolean | number
|
||||
@@ -4181,6 +4077,16 @@ export interface ApprovedAccessDomainGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface FileWithSignedUrlGenqlSelection{
|
||||
id?: boolean | number
|
||||
path?: boolean | number
|
||||
size?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
url?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitationGenqlSelection{
|
||||
id?: boolean | number
|
||||
email?: boolean | number
|
||||
@@ -4676,6 +4582,80 @@ export interface WorkspaceInviteHashValidGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface RecordIdentifierGenqlSelection{
|
||||
id?: boolean | number
|
||||
labelIdentifier?: boolean | number
|
||||
imageIdentifier?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface NavigationMenuItemGenqlSelection{
|
||||
id?: boolean | number
|
||||
userWorkspaceId?: boolean | number
|
||||
targetRecordId?: boolean | number
|
||||
targetObjectMetadataId?: boolean | number
|
||||
viewId?: boolean | number
|
||||
type?: boolean | number
|
||||
name?: boolean | number
|
||||
link?: boolean | number
|
||||
icon?: boolean | number
|
||||
color?: boolean | number
|
||||
folderId?: boolean | number
|
||||
position?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
targetRecordIdentifier?: RecordIdentifierGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventPropertiesGenqlSelection{
|
||||
updatedFields?: boolean | number
|
||||
before?: boolean | number
|
||||
after?: boolean | number
|
||||
diff?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface MetadataEventGenqlSelection{
|
||||
type?: boolean | number
|
||||
metadataName?: boolean | number
|
||||
recordId?: boolean | number
|
||||
properties?: ObjectRecordEventPropertiesGenqlSelection
|
||||
updatedCollectionHash?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventGenqlSelection{
|
||||
action?: boolean | number
|
||||
objectNameSingular?: boolean | number
|
||||
recordId?: boolean | number
|
||||
userId?: boolean | number
|
||||
workspaceMemberId?: boolean | number
|
||||
properties?: ObjectRecordEventPropertiesGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ObjectRecordEventWithQueryIdsGenqlSelection{
|
||||
queryIds?: boolean | number
|
||||
objectRecordEvent?: ObjectRecordEventGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface EventSubscriptionGenqlSelection{
|
||||
eventStreamId?: boolean | number
|
||||
objectRecordEventsWithQueryIds?: ObjectRecordEventWithQueryIdsGenqlSelection
|
||||
metadataEvents?: MetadataEventGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface LogicFunctionExecutionResultGenqlSelection{
|
||||
/** Execution result in JSON format */
|
||||
data?: boolean | number
|
||||
@@ -6266,15 +6246,12 @@ export interface WorkspaceMigrationInput {actions: WorkspaceMigrationDeleteActio
|
||||
export interface WorkspaceMigrationDeleteActionInput {type: WorkspaceMigrationActionType,metadataName: AllMetadataName,universalIdentifier: Scalars['String']}
|
||||
|
||||
export interface SubscriptionGenqlSelection{
|
||||
onDbEvent?: (OnDbEventGenqlSelection & { __args: {input: OnDbEventInput} })
|
||||
onEventSubscription?: (EventSubscriptionGenqlSelection & { __args: {eventStreamId: Scalars['String']} })
|
||||
logicFunctionLogs?: (LogicFunctionLogsGenqlSelection & { __args: {input: LogicFunctionLogsInput} })
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface OnDbEventInput {action?: (DatabaseEventAction | null),objectNameSingular?: (Scalars['String'] | null),recordId?: (Scalars['UUID'] | null)}
|
||||
|
||||
export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null),applicationUniversalIdentifier?: (Scalars['UUID'] | null),name?: (Scalars['String'] | null),id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null)}
|
||||
|
||||
|
||||
@@ -6822,78 +6799,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
|
||||
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
|
||||
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
|
||||
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
|
||||
return RecordIdentifier_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
|
||||
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
|
||||
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
|
||||
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
|
||||
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
|
||||
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
|
||||
return MetadataEvent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
|
||||
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
|
||||
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
|
||||
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
|
||||
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
|
||||
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
|
||||
return EventSubscription_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const OnDbEvent_possibleTypes: string[] = ['OnDbEvent']
|
||||
export const isOnDbEvent = (obj?: { __typename?: any } | null): obj is OnDbEvent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isOnDbEvent"')
|
||||
return OnDbEvent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const Analytics_possibleTypes: string[] = ['Analytics']
|
||||
export const isAnalytics = (obj?: { __typename?: any } | null): obj is Analytics => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAnalytics"')
|
||||
@@ -7062,6 +6967,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
|
||||
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
|
||||
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
|
||||
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
|
||||
@@ -7550,6 +7463,62 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
|
||||
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
|
||||
return RecordIdentifier_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
|
||||
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
|
||||
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
|
||||
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
|
||||
return ObjectRecordEventProperties_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const MetadataEvent_possibleTypes: string[] = ['MetadataEvent']
|
||||
export const isMetadataEvent = (obj?: { __typename?: any } | null): obj is MetadataEvent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isMetadataEvent"')
|
||||
return MetadataEvent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEvent_possibleTypes: string[] = ['ObjectRecordEvent']
|
||||
export const isObjectRecordEvent = (obj?: { __typename?: any } | null): obj is ObjectRecordEvent => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEvent"')
|
||||
return ObjectRecordEvent_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ObjectRecordEventWithQueryIds_possibleTypes: string[] = ['ObjectRecordEventWithQueryIds']
|
||||
export const isObjectRecordEventWithQueryIds = (obj?: { __typename?: any } | null): obj is ObjectRecordEventWithQueryIds => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventWithQueryIds"')
|
||||
return ObjectRecordEventWithQueryIds_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const EventSubscription_possibleTypes: string[] = ['EventSubscription']
|
||||
export const isEventSubscription = (obj?: { __typename?: any } | null): obj is EventSubscription => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isEventSubscription"')
|
||||
return EventSubscription_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
|
||||
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
|
||||
@@ -8591,29 +8560,6 @@ export const enumPageLayoutType = {
|
||||
DASHBOARD: 'DASHBOARD' as const
|
||||
}
|
||||
|
||||
export const enumNavigationMenuItemType = {
|
||||
VIEW: 'VIEW' as const,
|
||||
FOLDER: 'FOLDER' as const,
|
||||
LINK: 'LINK' as const,
|
||||
OBJECT: 'OBJECT' as const,
|
||||
RECORD: 'RECORD' as const
|
||||
}
|
||||
|
||||
export const enumMetadataEventAction = {
|
||||
CREATED: 'CREATED' as const,
|
||||
UPDATED: 'UPDATED' as const,
|
||||
DELETED: 'DELETED' as const
|
||||
}
|
||||
|
||||
export const enumDatabaseEventAction = {
|
||||
CREATED: 'CREATED' as const,
|
||||
UPDATED: 'UPDATED' as const,
|
||||
DELETED: 'DELETED' as const,
|
||||
DESTROYED: 'DESTROYED' as const,
|
||||
RESTORED: 'RESTORED' as const,
|
||||
UPSERTED: 'UPSERTED' as const
|
||||
}
|
||||
|
||||
export const enumBillingPlanKey = {
|
||||
PRO: 'PRO' as const,
|
||||
ENTERPRISE: 'ENTERPRISE' as const
|
||||
@@ -8692,6 +8638,29 @@ export const enumRelationType = {
|
||||
MANY_TO_ONE: 'MANY_TO_ONE' as const
|
||||
}
|
||||
|
||||
export const enumNavigationMenuItemType = {
|
||||
VIEW: 'VIEW' as const,
|
||||
FOLDER: 'FOLDER' as const,
|
||||
LINK: 'LINK' as const,
|
||||
OBJECT: 'OBJECT' as const,
|
||||
RECORD: 'RECORD' as const
|
||||
}
|
||||
|
||||
export const enumMetadataEventAction = {
|
||||
CREATED: 'CREATED' as const,
|
||||
UPDATED: 'UPDATED' as const,
|
||||
DELETED: 'DELETED' as const
|
||||
}
|
||||
|
||||
export const enumDatabaseEventAction = {
|
||||
CREATED: 'CREATED' as const,
|
||||
UPDATED: 'UPDATED' as const,
|
||||
DELETED: 'DELETED' as const,
|
||||
DESTROYED: 'DESTROYED' as const,
|
||||
RESTORED: 'RESTORED' as const,
|
||||
UPSERTED: 'UPSERTED' as const
|
||||
}
|
||||
|
||||
export const enumLogicFunctionExecutionStatus = {
|
||||
IDLE: 'IDLE' as const,
|
||||
SUCCESS: 'SUCCESS' as const,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+7
@@ -1,3 +1,5 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum DatabaseEventAction {
|
||||
CREATED = 'created',
|
||||
UPDATED = 'updated',
|
||||
@@ -6,3 +8,8 @@ export enum DatabaseEventAction {
|
||||
RESTORED = 'restored',
|
||||
UPSERTED = 'upserted',
|
||||
}
|
||||
|
||||
registerEnumType(DatabaseEventAction, {
|
||||
name: 'DatabaseEventAction',
|
||||
description: 'Database Event Action',
|
||||
});
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ import { CallWebhookJobsJob } from 'src/engine/metadata-modules/webhook/jobs/cal
|
||||
import { WorkspaceEventBatchForWebhook } from 'src/engine/metadata-modules/webhook/types/workspace-event-batch-for-webhook.type';
|
||||
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
|
||||
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
|
||||
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
|
||||
import { UpsertTimelineActivityFromInternalEvent } from 'src/modules/timeline/jobs/upsert-timeline-activity-from-internal-event.job';
|
||||
|
||||
@Injectable()
|
||||
@@ -32,7 +32,7 @@ export class EntityEventsToDbListener {
|
||||
private readonly webhookQueueService: MessageQueueService,
|
||||
@InjectMessageQueue(MessageQueue.triggerQueue)
|
||||
private readonly triggerQueueService: MessageQueueService,
|
||||
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
|
||||
private readonly objectRecordEventPublisher: ObjectRecordEventPublisher,
|
||||
) {}
|
||||
|
||||
@OnDatabaseBatchEvent('*', DatabaseEventAction.CREATED)
|
||||
@@ -79,7 +79,7 @@ export class EntityEventsToDbListener {
|
||||
};
|
||||
|
||||
const promises = [
|
||||
this.workspaceEventEmitterService.publish(batchEvent),
|
||||
this.objectRecordEventPublisher.publish(batchEvent),
|
||||
this.webhookQueueService.add<WorkspaceEventBatchForWebhook<T>>(
|
||||
CallWebhookJobsJob.name,
|
||||
batchEventForWebhook,
|
||||
|
||||
@@ -65,7 +65,6 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
|
||||
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
|
||||
import { MetadataEventEmitterModule } from 'src/engine/metadata-event-emitter/metadata-event-emitter.module';
|
||||
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
|
||||
import { ChannelSyncModule } from 'src/modules/connected-account/channel-sync/channel-sync.module';
|
||||
import { DashboardModule } from 'src/modules/dashboard/dashboard.module';
|
||||
@@ -110,7 +109,6 @@ import { FileModule } from './file/file.module';
|
||||
PostgresCredentialsModule,
|
||||
WorkflowApiModule,
|
||||
WorkspaceEventEmitterModule,
|
||||
MetadataEventEmitterModule,
|
||||
ActorModule,
|
||||
TelemetryModule,
|
||||
AdminPanelModule,
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { MetadataEventsToDbListener } from 'src/engine/metadata-event-emitter/listeners/metadata-events-to-db.listener';
|
||||
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [SubscriptionsModule, WorkspaceCacheModule],
|
||||
providers: [MetadataEventEmitter, MetadataEventsToDbListener],
|
||||
exports: [MetadataEventEmitter],
|
||||
})
|
||||
export class MetadataEventEmitterModule {}
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
|
||||
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
|
||||
import { type FlatWebhook } from 'src/engine/metadata-modules/flat-webhook/types/flat-webhook.type';
|
||||
import { CallWebhookJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook.job';
|
||||
import { type CallMetadataWebhookJobData } from 'src/engine/metadata-modules/webhook/types/webhook-job-data.type';
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { ObjectRecord } from 'twenty-shared/types';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
|
||||
registerEnumType(DatabaseEventAction, {
|
||||
name: 'DatabaseEventAction',
|
||||
description: 'Database Event Action',
|
||||
});
|
||||
|
||||
@ObjectType('OnDbEvent')
|
||||
export class OnDbEventDTO {
|
||||
@Field(() => DatabaseEventAction)
|
||||
action: DatabaseEventAction;
|
||||
|
||||
@Field(() => String)
|
||||
objectNameSingular: string;
|
||||
|
||||
@Field()
|
||||
eventDate: Date;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
record: ObjectRecord;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
updatedFields?: string[];
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class OnDbEventInput {
|
||||
@Field(() => DatabaseEventAction, { nullable: true })
|
||||
action?: DatabaseEventAction;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
objectNameSingular?: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
recordId?: string;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export enum SubscriptionChannel {
|
||||
DATABASE_EVENT_CHANNEL = 'DATABASE_EVENT_CHANNEL',
|
||||
LOGIC_FUNCTION_LOGS_CHANNEL = 'LOGIC_FUNCTION_LOGS_CHANNEL',
|
||||
EVENT_STREAM_CHANNEL = 'EVENT_STREAM_CHANNEL',
|
||||
}
|
||||
|
||||
+1
-3
@@ -10,9 +10,7 @@ import {
|
||||
} from 'src/engine/subscriptions/event-stream.exception';
|
||||
|
||||
@Catch(EventStreamException)
|
||||
export class WorkspaceEventEmitterExceptionFilter
|
||||
implements GqlExceptionFilter
|
||||
{
|
||||
export class EventStreamExceptionFilter implements GqlExceptionFilter {
|
||||
catch(exception: EventStreamException) {
|
||||
switch (exception.code) {
|
||||
case EventStreamExceptionCode.EVENT_STREAM_ALREADY_EXISTS:
|
||||
+5
-45
@@ -19,10 +19,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { EVENT_STREAM_TTL_MS } from 'src/engine/subscriptions/constants/event-stream-ttl.constant';
|
||||
import { AddQuerySubscriptionInput } from 'src/engine/subscriptions/dtos/add-query-subscription.input';
|
||||
import { EventSubscriptionDTO } from 'src/engine/subscriptions/dtos/event-subscription.dto';
|
||||
import { OnDbEventDTO } from 'src/engine/subscriptions/dtos/on-db-event.dto';
|
||||
import { OnDbEventInput } from 'src/engine/subscriptions/dtos/on-db-event.input';
|
||||
import { RemoveQueryFromEventStreamInput } from 'src/engine/subscriptions/dtos/remove-query-subscription.input';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
import { EventStreamExceptionFilter } from 'src/engine/subscriptions/event-stream-exception.filter';
|
||||
import {
|
||||
EventStreamException,
|
||||
EventStreamExceptionCode,
|
||||
@@ -30,57 +28,19 @@ import {
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
|
||||
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/workspace-event-emitter/utils/wrap-async-iterator-with-lifecycle';
|
||||
import { WorkspaceEventEmitterExceptionFilter } from 'src/engine/workspace-event-emitter/workspace-event-emitter-exception.filter';
|
||||
|
||||
import { eventStreamIdToChannelId } from './utils/get-channel-id-from-event-stream-id';
|
||||
import { eventStreamIdToChannelId } from 'src/engine/subscriptions/utils/get-channel-id-from-event-stream-id';
|
||||
import { wrapAsyncIteratorWithLifecycle } from 'src/engine/subscriptions/utils/wrap-async-iterator-with-lifecycle';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, NoPermissionGuard)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@UseFilters(
|
||||
WorkspaceEventEmitterExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
export class WorkspaceEventEmitterResolver {
|
||||
@UseFilters(EventStreamExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
|
||||
export class EventStreamResolver {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly eventStreamService: EventStreamService,
|
||||
) {}
|
||||
|
||||
@Subscription(() => OnDbEventDTO, {
|
||||
filter: (
|
||||
payload: { onDbEvent: OnDbEventDTO },
|
||||
variables: { input: OnDbEventInput },
|
||||
) => {
|
||||
const isActionMatching =
|
||||
!isDefined(variables.input.action) ||
|
||||
payload.onDbEvent.action === variables.input.action;
|
||||
|
||||
const isObjectNameSingularMatching =
|
||||
!isDefined(variables.input.objectNameSingular) ||
|
||||
payload.onDbEvent.objectNameSingular ===
|
||||
variables.input.objectNameSingular;
|
||||
|
||||
const isRecordIdMatching =
|
||||
!isDefined(variables.input.recordId) ||
|
||||
payload.onDbEvent.record.id === variables.input.recordId;
|
||||
|
||||
return (
|
||||
isActionMatching && isObjectNameSingularMatching && isRecordIdMatching
|
||||
);
|
||||
},
|
||||
})
|
||||
onDbEvent(
|
||||
@Args('input') _: OnDbEventInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
) {
|
||||
return this.subscriptionService.subscribe({
|
||||
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Subscription(() => EventSubscriptionDTO, {
|
||||
nullable: true,
|
||||
resolve: (
|
||||
+2
-2
@@ -6,8 +6,8 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { getWorkspaceAuthContext } from 'src/engine/core-modules/auth/storage/workspace-auth-context.storage';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
|
||||
import { computeMetadataEventName } from 'src/engine/metadata-event-emitter/utils/compute-metadata-event-name.util';
|
||||
import { MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
|
||||
import { computeMetadataEventName } from 'src/engine/subscriptions/metadata-event/utils/compute-metadata-event-name.util';
|
||||
import {
|
||||
AllMetadataEventName,
|
||||
AllMetadataEventType,
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
|
||||
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
|
||||
import { type EventStreamPayload } from 'src/engine/subscriptions/types/event-stream-payload.type';
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { enrichFieldMetadataEventWithRelations } from 'src/engine/subscriptions/metadata-event/utils/enrich-field-metadata-event-with-relations.util';
|
||||
|
||||
@Injectable()
|
||||
export class MetadataEventPublisher {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly eventStreamService: EventStreamService,
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
|
||||
) {}
|
||||
|
||||
async publish(metadataEventBatch: MetadataEventBatch): Promise<void> {
|
||||
const workspaceId = metadataEventBatch.workspaceId;
|
||||
|
||||
const activeStreamIds =
|
||||
await this.eventStreamService.getActiveStreamIds(workspaceId);
|
||||
|
||||
if (activeStreamIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const streamsData = await this.eventStreamService.getStreamsData(
|
||||
workspaceId,
|
||||
activeStreamIds,
|
||||
);
|
||||
|
||||
const enrichedBatch =
|
||||
await this.enrichMetadataEventBatch(metadataEventBatch);
|
||||
|
||||
const streamIdsToRemove: string[] = [];
|
||||
|
||||
for (const [streamChannelId, streamData] of streamsData) {
|
||||
if (!isDefined(streamData)) {
|
||||
streamIdsToRemove.push(streamChannelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.publishToStream({
|
||||
streamChannelId,
|
||||
metadataEventBatch: enrichedBatch,
|
||||
});
|
||||
}
|
||||
|
||||
await this.eventStreamService.removeFromActiveStreams(
|
||||
workspaceId,
|
||||
streamIdsToRemove,
|
||||
);
|
||||
}
|
||||
|
||||
private async enrichMetadataEventBatch(
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): Promise<MetadataEventBatch> {
|
||||
switch (metadataEventBatch.metadataName) {
|
||||
case 'fieldMetadata':
|
||||
return this.enrichFieldMetadataEventsWithRelations(
|
||||
metadataEventBatch as MetadataEventBatch<'fieldMetadata'>,
|
||||
);
|
||||
case 'navigationMenuItem':
|
||||
return this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
|
||||
metadataEventBatch as MetadataEventBatch<'navigationMenuItem'>,
|
||||
);
|
||||
case 'objectMetadata':
|
||||
return this.resolveObjectMetadataStandardOverrides(
|
||||
metadataEventBatch as MetadataEventBatch<'objectMetadata'>,
|
||||
);
|
||||
default:
|
||||
return metadataEventBatch;
|
||||
}
|
||||
}
|
||||
|
||||
private async publishToStream({
|
||||
streamChannelId,
|
||||
metadataEventBatch,
|
||||
}: {
|
||||
streamChannelId: string;
|
||||
metadataEventBatch: MetadataEventBatch;
|
||||
}): Promise<void> {
|
||||
if (!isNonEmptyArray(metadataEventBatch.events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
|
||||
...metadataEvent,
|
||||
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
|
||||
}));
|
||||
|
||||
const payload: EventStreamPayload = {
|
||||
objectRecordEventsWithQueryIds: [],
|
||||
metadataEvents,
|
||||
};
|
||||
|
||||
await this.subscriptionService.publishToEventStream({
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
eventStreamChannelId: streamChannelId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async enrichFieldMetadataEventsWithRelations(
|
||||
metadataEventBatch: MetadataEventBatch<'fieldMetadata'>,
|
||||
): Promise<MetadataEventBatch<'fieldMetadata'>> {
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const enrichedEvents = metadataEventBatch.events.map((event) => {
|
||||
if (
|
||||
!('after' in event.properties) ||
|
||||
!isDefined(event.properties.after)
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const enrichedAfter = enrichFieldMetadataEventWithRelations({
|
||||
record: event.properties.after as Record<string, unknown>,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
...event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
after: enrichedAfter,
|
||||
},
|
||||
} as typeof event;
|
||||
});
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
|
||||
metadataEventBatch: MetadataEventBatch<'navigationMenuItem'>,
|
||||
): Promise<MetadataEventBatch<'navigationMenuItem'>> {
|
||||
const enrichedEvents = await Promise.all(
|
||||
metadataEventBatch.events.map(async (event) => {
|
||||
if (
|
||||
!('after' in event.properties) ||
|
||||
!isDefined(event.properties.after)
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const after = event.properties.after as Record<string, unknown>;
|
||||
const targetRecordId = after.targetRecordId as string | undefined;
|
||||
const targetObjectMetadataId = after.targetObjectMetadataId as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const targetRecordIdentifier =
|
||||
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
|
||||
{
|
||||
targetRecordId,
|
||||
targetObjectMetadataId,
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const enrichedAfter: Record<string, unknown> = {
|
||||
...after,
|
||||
targetRecordIdentifier,
|
||||
};
|
||||
|
||||
return {
|
||||
...event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
after: enrichedAfter,
|
||||
},
|
||||
} as typeof event;
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private resolveObjectMetadataStandardOverrides(
|
||||
metadataEventBatch: MetadataEventBatch<'objectMetadata'>,
|
||||
): MetadataEventBatch<'objectMetadata'> {
|
||||
const enrichedEvents = metadataEventBatch.events.map((event) => {
|
||||
const enrichedProperties = { ...event.properties };
|
||||
|
||||
if (
|
||||
'before' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.before)
|
||||
) {
|
||||
enrichedProperties.before =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.before as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.before;
|
||||
}
|
||||
|
||||
if (
|
||||
'after' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.after)
|
||||
) {
|
||||
enrichedProperties.after =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.after as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.after;
|
||||
}
|
||||
|
||||
return { ...event, properties: enrichedProperties } as typeof event;
|
||||
});
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private applyStandardOverridesToObjectMetadataRecord(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const standardOverrides = record.standardOverrides as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
if (!isDefined(standardOverrides)) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const resolved = { ...record };
|
||||
|
||||
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
|
||||
if (isDefined(standardOverrides[key])) {
|
||||
resolved[key] = standardOverrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -6,12 +6,12 @@ import { AllMetadataName } from 'twenty-shared/metadata';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
|
||||
import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
import { CallWebhookJobsForMetadataJob } from 'src/engine/metadata-modules/webhook/jobs/call-webhook-jobs-for-metadata.job';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type';
|
||||
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
|
||||
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
|
||||
import { type AllMetadataEventType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/metadata-event';
|
||||
|
||||
@Injectable()
|
||||
@@ -19,7 +19,7 @@ export class MetadataEventsToDbListener {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.webhookQueue)
|
||||
private readonly webhookQueueService: MessageQueueService,
|
||||
private readonly workspaceEventEmitterService: WorkspaceEventEmitterService,
|
||||
private readonly metadataEventPublisher: MetadataEventPublisher,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@@ -68,7 +68,7 @@ export class MetadataEventsToDbListener {
|
||||
|
||||
const updatedCollectionHash = cacheHashes[cacheKeyName];
|
||||
|
||||
await this.workspaceEventEmitterService.publish({
|
||||
await this.metadataEventPublisher.publish({
|
||||
...metadataEventBatch,
|
||||
updatedCollectionHash,
|
||||
});
|
||||
+6
-15
@@ -22,8 +22,7 @@ import { type EventStreamData } from 'src/engine/subscriptions/types/event-strea
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
|
||||
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
|
||||
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
|
||||
|
||||
jest.mock(
|
||||
'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util',
|
||||
@@ -70,8 +69,8 @@ const buildFlatFieldMetadataMaps = (
|
||||
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
|
||||
);
|
||||
|
||||
describe('WorkspaceEventEmitterService', () => {
|
||||
let service: WorkspaceEventEmitterService;
|
||||
describe('ObjectRecordEventPublisher', () => {
|
||||
let service: ObjectRecordEventPublisher;
|
||||
let mockSubscriptionService: jest.Mocked<
|
||||
Pick<SubscriptionService, 'publish' | 'publishToEventStream'>
|
||||
>;
|
||||
@@ -273,7 +272,7 @@ describe('WorkspaceEventEmitterService', () => {
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceEventEmitterService,
|
||||
ObjectRecordEventPublisher,
|
||||
{
|
||||
provide: SubscriptionService,
|
||||
useValue: mockSubscriptionService,
|
||||
@@ -302,19 +301,11 @@ describe('WorkspaceEventEmitterService', () => {
|
||||
provide: CommonSelectFieldsHelper,
|
||||
useValue: new CommonSelectFieldsHelper(),
|
||||
},
|
||||
{
|
||||
provide: NavigationMenuItemRecordIdentifierService,
|
||||
useValue: {
|
||||
enrichNavigationMenuItemEventsWithRecordIdentifiers: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceEventEmitterService>(
|
||||
WorkspaceEventEmitterService,
|
||||
service = module.get<ObjectRecordEventPublisher>(
|
||||
ObjectRecordEventPublisher,
|
||||
);
|
||||
});
|
||||
|
||||
+31
-278
@@ -13,7 +13,6 @@ import {
|
||||
import {
|
||||
combineFilters,
|
||||
isDefined,
|
||||
isNonEmptyArray,
|
||||
isRecordGqlOperationSignature,
|
||||
} from 'twenty-shared/utils';
|
||||
import { FindOptionsRelations, ObjectLiteral } from 'typeorm';
|
||||
@@ -25,20 +24,14 @@ import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/
|
||||
import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type';
|
||||
import { type MetadataEventBatch } from 'src/engine/metadata-event-emitter/types/metadata-event-batch.type';
|
||||
import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
|
||||
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
|
||||
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
|
||||
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
|
||||
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
|
||||
import { transformEventToWebhookEvent } from 'src/engine/metadata-modules/webhook/utils/transform-event-to-webhook-event';
|
||||
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import {
|
||||
@@ -54,8 +47,9 @@ import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twent
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
|
||||
import { parseEventNameOrThrow } from 'src/engine/workspace-event-emitter/utils/parse-event-name';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceEventEmitterService {
|
||||
export class ObjectRecordEventPublisher {
|
||||
constructor(
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
private readonly eventStreamService: EventStreamService,
|
||||
@@ -64,57 +58,12 @@ export class WorkspaceEventEmitterService {
|
||||
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper,
|
||||
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
|
||||
) {}
|
||||
|
||||
async publish(
|
||||
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
|
||||
): Promise<void> {
|
||||
if (!this.isMetadataEventBatch(eventBatch)) {
|
||||
await this.publishToLegacyChannel(eventBatch);
|
||||
}
|
||||
|
||||
await this.publishToEventStreams(eventBatch);
|
||||
}
|
||||
|
||||
private isMetadataEventBatch(
|
||||
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
|
||||
): eventBatch is MetadataEventBatch {
|
||||
return 'metadataName' in eventBatch;
|
||||
}
|
||||
|
||||
private async publishToLegacyChannel(
|
||||
eventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
|
||||
): Promise<void> {
|
||||
const [nameSingular, operation] = eventBatch.name.split('.');
|
||||
|
||||
for (const eventData of eventBatch.events) {
|
||||
const { record, updatedFields } = transformEventToWebhookEvent({
|
||||
eventName: eventBatch.name,
|
||||
event: eventData,
|
||||
});
|
||||
|
||||
const event = {
|
||||
action: operation,
|
||||
objectNameSingular: nameSingular,
|
||||
eventDate: new Date(),
|
||||
record,
|
||||
...(updatedFields && { updatedFields }),
|
||||
};
|
||||
|
||||
await this.subscriptionService.publish({
|
||||
channel: SubscriptionChannel.DATABASE_EVENT_CHANNEL,
|
||||
workspaceId: eventBatch.workspaceId,
|
||||
payload: { onDbEvent: event },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async publishToEventStreams(
|
||||
eventBatch: WorkspaceEventBatch<ObjectRecordEvent> | MetadataEventBatch,
|
||||
): Promise<void> {
|
||||
const workspaceId = eventBatch.workspaceId;
|
||||
const isMetadata = this.isMetadataEventBatch(eventBatch);
|
||||
|
||||
const activeStreamIds =
|
||||
await this.eventStreamService.getActiveStreamIds(workspaceId);
|
||||
@@ -128,53 +77,28 @@ export class WorkspaceEventEmitterService {
|
||||
activeStreamIds,
|
||||
);
|
||||
|
||||
const { permissionsContext, flatWorkspaceMemberMaps } =
|
||||
await this.fetchObjectRecordStreamContext(workspaceId);
|
||||
|
||||
const streamIdsToRemove: string[] = [];
|
||||
|
||||
const objectRecordStreamContext = !isMetadata
|
||||
? await this.fetchObjectRecordStreamContext(workspaceId)
|
||||
: undefined;
|
||||
|
||||
const enrichedMetadataEventBatch = isMetadata
|
||||
? await this.enrichFieldMetadataEventsWithRelations(
|
||||
eventBatch as MetadataEventBatch,
|
||||
)
|
||||
.then((batch) =>
|
||||
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
|
||||
batch,
|
||||
),
|
||||
)
|
||||
.then((batch) => this.resolveObjectMetadataStandardOverrides(batch))
|
||||
: undefined;
|
||||
|
||||
for (const [streamChannelId, streamData] of streamsData) {
|
||||
if (!isDefined(streamData)) {
|
||||
streamIdsToRemove.push(streamChannelId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMetadata && isDefined(enrichedMetadataEventBatch)) {
|
||||
await this.processMetadataStreamEvents(
|
||||
streamChannelId,
|
||||
streamData,
|
||||
enrichedMetadataEventBatch,
|
||||
);
|
||||
} else if (!isMetadata) {
|
||||
if (Object.keys(streamData.queries).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isDefined(objectRecordStreamContext)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processObjectRecordStreamEvents(
|
||||
streamChannelId,
|
||||
streamData,
|
||||
eventBatch as WorkspaceEventBatch<ObjectRecordEvent>,
|
||||
objectRecordStreamContext.permissionsContext,
|
||||
objectRecordStreamContext.flatWorkspaceMemberMaps,
|
||||
);
|
||||
if (Object.keys(streamData.queries).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processObjectRecordStreamEvents({
|
||||
streamChannelId,
|
||||
streamData,
|
||||
workspaceEventBatch: eventBatch,
|
||||
permissionsContext,
|
||||
flatWorkspaceMemberMaps,
|
||||
});
|
||||
}
|
||||
|
||||
await this.eventStreamService.removeFromActiveStreams(
|
||||
@@ -193,198 +117,25 @@ export class WorkspaceEventEmitterService {
|
||||
return { permissionsContext, flatWorkspaceMemberMaps };
|
||||
}
|
||||
|
||||
private async processMetadataStreamEvents(
|
||||
streamChannelId: string,
|
||||
_streamData: EventStreamData,
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): Promise<void> {
|
||||
if (!isNonEmptyArray(metadataEventBatch.events)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataEvents = metadataEventBatch.events.map((metadataEvent) => ({
|
||||
...metadataEvent,
|
||||
updatedCollectionHash: metadataEventBatch.updatedCollectionHash,
|
||||
}));
|
||||
|
||||
const payload: EventStreamPayload = {
|
||||
objectRecordEventsWithQueryIds: [],
|
||||
metadataEvents,
|
||||
};
|
||||
|
||||
await this.subscriptionService.publishToEventStream({
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
eventStreamChannelId: streamChannelId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async enrichFieldMetadataEventsWithRelations(
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): Promise<MetadataEventBatch> {
|
||||
if (metadataEventBatch.metadataName !== 'fieldMetadata') {
|
||||
return metadataEventBatch;
|
||||
}
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps', 'flatObjectMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const enrichedEvents = metadataEventBatch.events.map((event) => {
|
||||
if (
|
||||
!('after' in event.properties) ||
|
||||
!isDefined(event.properties.after)
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const enrichedAfter = enrichFieldMetadataEventWithRelations({
|
||||
record: event.properties.after as Record<string, unknown>,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
return {
|
||||
...event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
after: enrichedAfter,
|
||||
},
|
||||
} as typeof event;
|
||||
});
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): Promise<MetadataEventBatch> {
|
||||
if (metadataEventBatch.metadataName !== 'navigationMenuItem') {
|
||||
return metadataEventBatch;
|
||||
}
|
||||
|
||||
const enrichedEvents = await Promise.all(
|
||||
metadataEventBatch.events.map(async (event) => {
|
||||
if (
|
||||
!('after' in event.properties) ||
|
||||
!isDefined(event.properties.after)
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const after = event.properties.after as Record<string, unknown>;
|
||||
const targetRecordId = after.targetRecordId as string | undefined;
|
||||
const targetObjectMetadataId = after.targetObjectMetadataId as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const targetRecordIdentifier =
|
||||
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
|
||||
{
|
||||
targetRecordId,
|
||||
targetObjectMetadataId,
|
||||
workspaceId: metadataEventBatch.workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const enrichedAfter: Record<string, unknown> = {
|
||||
...after,
|
||||
targetRecordIdentifier,
|
||||
};
|
||||
|
||||
return {
|
||||
...event,
|
||||
properties: {
|
||||
...event.properties,
|
||||
after: enrichedAfter,
|
||||
},
|
||||
} as typeof event;
|
||||
}),
|
||||
);
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private resolveObjectMetadataStandardOverrides(
|
||||
metadataEventBatch: MetadataEventBatch,
|
||||
): MetadataEventBatch {
|
||||
if (metadataEventBatch.metadataName !== 'objectMetadata') {
|
||||
return metadataEventBatch;
|
||||
}
|
||||
|
||||
const enrichedEvents = metadataEventBatch.events.map((event) => {
|
||||
const enrichedProperties = { ...event.properties };
|
||||
|
||||
if (
|
||||
'before' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.before)
|
||||
) {
|
||||
enrichedProperties.before =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.before as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.before;
|
||||
}
|
||||
|
||||
if (
|
||||
'after' in enrichedProperties &&
|
||||
isDefined(enrichedProperties.after)
|
||||
) {
|
||||
enrichedProperties.after =
|
||||
this.applyStandardOverridesToObjectMetadataRecord(
|
||||
enrichedProperties.after as Record<string, unknown>,
|
||||
) as typeof enrichedProperties.after;
|
||||
}
|
||||
|
||||
return { ...event, properties: enrichedProperties } as typeof event;
|
||||
});
|
||||
|
||||
return { ...metadataEventBatch, events: enrichedEvents };
|
||||
}
|
||||
|
||||
private applyStandardOverridesToObjectMetadataRecord(
|
||||
record: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const standardOverrides = record.standardOverrides as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
if (!isDefined(standardOverrides)) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const resolved = { ...record };
|
||||
|
||||
for (const key of OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES) {
|
||||
if (isDefined(standardOverrides[key])) {
|
||||
resolved[key] = standardOverrides[key];
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async processObjectRecordStreamEvents(
|
||||
streamChannelId: string,
|
||||
streamData: EventStreamData,
|
||||
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>,
|
||||
private async processObjectRecordStreamEvents({
|
||||
streamChannelId,
|
||||
streamData,
|
||||
workspaceEventBatch,
|
||||
permissionsContext,
|
||||
flatWorkspaceMemberMaps,
|
||||
}: {
|
||||
streamChannelId: string;
|
||||
streamData: EventStreamData;
|
||||
workspaceEventBatch: WorkspaceEventBatch<ObjectRecordEvent>;
|
||||
permissionsContext: {
|
||||
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
|
||||
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
userWorkspaceRoleMap: Record<string, string>;
|
||||
rolesPermissions: ObjectsPermissionsByRoleId;
|
||||
},
|
||||
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
|
||||
): Promise<void> {
|
||||
};
|
||||
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps;
|
||||
}): Promise<void> {
|
||||
const { userWorkspaceId } = streamData.authContext;
|
||||
|
||||
if (!isDefined(userWorkspaceId)) {
|
||||
@@ -470,7 +221,9 @@ export class WorkspaceEventEmitterService {
|
||||
if (matchedEvents.length > 0) {
|
||||
await this.enrichEventBatchWithNestedRelations({
|
||||
objectMetadata: workspaceEventBatch.objectMetadata,
|
||||
events: matchedEvents.map((e) => e.objectRecordEvent),
|
||||
events: matchedEvents.map(
|
||||
(matchedEvent) => matchedEvent.objectRecordEvent,
|
||||
),
|
||||
streamData,
|
||||
permissionsContext,
|
||||
workspaceId: workspaceEventBatch.workspaceId,
|
||||
@@ -1,14 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { CacheStorageModule } from 'src/engine/core-modules/cache-storage/cache-storage.module';
|
||||
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
|
||||
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { EventStreamResolver } from 'src/engine/subscriptions/event-stream.resolver';
|
||||
import { EventStreamService } from 'src/engine/subscriptions/event-stream.service';
|
||||
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
|
||||
import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher';
|
||||
import { MetadataEventsToDbListener } from 'src/engine/subscriptions/metadata-event/metadata-events-to-db.listener';
|
||||
import { ObjectRecordEventPublisher } from 'src/engine/subscriptions/object-record-event/object-record-event-publisher';
|
||||
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
RedisClientModule,
|
||||
@@ -16,8 +28,26 @@ import { SubscriptionService } from 'src/engine/subscriptions/subscription.servi
|
||||
CacheLockModule,
|
||||
MetricsModule,
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
NavigationMenuItemModule,
|
||||
],
|
||||
providers: [
|
||||
SubscriptionService,
|
||||
EventStreamService,
|
||||
EventStreamResolver,
|
||||
ObjectRecordEventPublisher,
|
||||
MetadataEventPublisher,
|
||||
MetadataEventEmitter,
|
||||
MetadataEventsToDbListener,
|
||||
ProcessNestedRelationsHelper,
|
||||
ProcessNestedRelationsV2Helper,
|
||||
CommonSelectFieldsHelper,
|
||||
],
|
||||
exports: [
|
||||
SubscriptionService,
|
||||
ObjectRecordEventPublisher,
|
||||
MetadataEventEmitter,
|
||||
],
|
||||
providers: [SubscriptionService, EventStreamService],
|
||||
exports: [SubscriptionService, EventStreamService],
|
||||
})
|
||||
export class SubscriptionsModule {}
|
||||
|
||||
-512
@@ -1,512 +0,0 @@
|
||||
import { isMetadataRecordMatchingFilter } from 'src/engine/workspace-event-emitter/utils/is-metadata-record-matching-filter.util';
|
||||
|
||||
const record = {
|
||||
id: '1',
|
||||
name: 'Test Object',
|
||||
label: 'testObject',
|
||||
isActive: true,
|
||||
count: 42,
|
||||
};
|
||||
|
||||
describe('isMetadataRecordMatchingFilter', () => {
|
||||
describe('empty filters', () => {
|
||||
it('should match any record when filter is empty', () => {
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter: {} })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('and filter', () => {
|
||||
it('should match when all sub-filters match', () => {
|
||||
const filter = {
|
||||
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: true } }],
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when any sub-filter fails', () => {
|
||||
const filter = {
|
||||
and: [{ name: { eq: 'Test Object' } }, { isActive: { eq: false } }],
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
|
||||
});
|
||||
|
||||
it('should match when and array is empty', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { and: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw when and value is not an array', () => {
|
||||
expect(() =>
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { and: 'invalid' } as any,
|
||||
}),
|
||||
).toThrow('Unexpected value for "and" filter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('or filter', () => {
|
||||
it('should match when any sub-filter matches', () => {
|
||||
const filter = {
|
||||
or: [{ name: { eq: 'Wrong Name' } }, { name: { eq: 'Test Object' } }],
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when no sub-filter matches', () => {
|
||||
const filter = {
|
||||
or: [{ name: { eq: 'Wrong' } }, { name: { eq: 'Also Wrong' } }],
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
|
||||
});
|
||||
|
||||
it('should match when or array is empty', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { or: [] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat or with an object as an and', () => {
|
||||
const filter = {
|
||||
or: { name: { eq: 'Test Object' } },
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw when or value is neither array nor object', () => {
|
||||
expect(() =>
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { or: 'invalid' } as any,
|
||||
}),
|
||||
).toThrow('Unexpected value for "or" filter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('not filter', () => {
|
||||
it('should negate a matching filter', () => {
|
||||
const filter = { not: { name: { eq: 'Test Object' } } };
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
|
||||
});
|
||||
|
||||
it('should negate a non-matching filter', () => {
|
||||
const filter = { not: { name: { eq: 'Wrong' } } };
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when not contains an empty object', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { not: {} },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('implicit and (multi-key filter)', () => {
|
||||
it('should treat multiple keys as an implicit AND', () => {
|
||||
const filter = {
|
||||
name: { eq: 'Test Object' },
|
||||
isActive: { eq: true },
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should fail if any key in implicit AND does not match', () => {
|
||||
const filter = {
|
||||
name: { eq: 'Test Object' },
|
||||
isActive: { eq: false },
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eq operator', () => {
|
||||
it('should match equal string values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { eq: 'Test Object' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match different string values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { eq: 'Other' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should match equal boolean values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { isActive: { eq: true } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('neq operator', () => {
|
||||
it('should match when values are different', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { neq: 'Other' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when values are equal', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { neq: 'Test Object' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('in operator', () => {
|
||||
it('should match when value is in the array', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { in: ['Test Object', 'Other'] } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is not in the array', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { in: ['A', 'B'] } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when in value is not an array', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { in: 'not-array' } } as any,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is operator', () => {
|
||||
it('should match NULL for undefined values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: { ...record, optional: undefined },
|
||||
filter: { optional: { is: 'NULL' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match NULL for defined values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { is: 'NULL' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should match NOT_NULL for defined values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { is: 'NOT_NULL' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match NOT_NULL for undefined values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: { ...record, optional: undefined },
|
||||
filter: { optional: { is: 'NOT_NULL' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('like operator', () => {
|
||||
it('should match with wildcard prefix', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { like: '%Object' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should match with wildcard suffix', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { like: 'Test%' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should match with wildcards on both sides', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { like: '%est Obj%' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when pattern does not match', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { like: 'wrong%' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should escape regex special characters in pattern', () => {
|
||||
const specialRecord = { ...record, name: 'foo.bar' };
|
||||
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: specialRecord,
|
||||
filter: { name: { like: 'foo.bar' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
// A dot in the pattern should NOT match any character
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: { ...record, name: 'fooXbar' },
|
||||
filter: { name: { like: 'foo.bar' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { like: '42' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ilike operator', () => {
|
||||
it('should match case-insensitively', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { ilike: '%test object%' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should escape regex special characters', () => {
|
||||
const specialRecord = { ...record, name: 'foo(bar)' };
|
||||
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: specialRecord,
|
||||
filter: { name: { ilike: 'FOO(BAR)' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-string values', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { ilike: '42' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gt operator', () => {
|
||||
it('should match when value is greater', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { gt: 40 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is equal', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { gt: 42 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when value is less', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { gt: 50 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gte operator', () => {
|
||||
it('should match when value is greater or equal', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { gte: 42 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is less', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { gte: 43 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lt operator', () => {
|
||||
it('should match when value is less', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { lt: 50 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is equal', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { lt: 42 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lte operator', () => {
|
||||
it('should match when value is less or equal', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { lte: 42 } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is greater', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { count: { lte: 41 } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown operator', () => {
|
||||
it('should throw for unsupported operators', () => {
|
||||
expect(() =>
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: { unknownOp: 'value' } } as any,
|
||||
}),
|
||||
).toThrow('Unsupported filter operator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested logical operators', () => {
|
||||
it('should handle deeply nested and/or/not', () => {
|
||||
const filter = {
|
||||
and: [
|
||||
{
|
||||
or: [
|
||||
{ name: { eq: 'Wrong' } },
|
||||
{ not: { isActive: { eq: false } } },
|
||||
],
|
||||
},
|
||||
{ count: { gte: 40 } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle not with nested and', () => {
|
||||
const filter = {
|
||||
not: {
|
||||
and: [{ name: { eq: 'Test Object' } }, { count: { gt: 100 } }],
|
||||
},
|
||||
};
|
||||
|
||||
// name matches but count > 100 fails, so AND = false, NOT = true
|
||||
expect(isMetadataRecordMatchingFilter({ record, filter })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should skip non-object field filters', () => {
|
||||
// When fieldFilter is a primitive, it is skipped (returns true)
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: 'Test Object' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip undefined field filters', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { name: undefined },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle records with missing fields', () => {
|
||||
expect(
|
||||
isMetadataRecordMatchingFilter({
|
||||
record: { id: '1' },
|
||||
filter: { name: { eq: 'Test' } },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type MetadataFilter = Record<string, unknown>;
|
||||
|
||||
type MetadataScalarFilter = {
|
||||
is?: 'NULL' | 'NOT_NULL';
|
||||
eq?: unknown;
|
||||
neq?: unknown;
|
||||
in?: unknown[];
|
||||
like?: string;
|
||||
ilike?: string;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
};
|
||||
|
||||
const isEmptyFilter = (filter: MetadataFilter): boolean =>
|
||||
Object.keys(filter).length === 0;
|
||||
|
||||
const isAndFilter = (filter: MetadataFilter): boolean =>
|
||||
'and' in filter && isDefined(filter.and);
|
||||
|
||||
const isOrFilter = (filter: MetadataFilter): boolean =>
|
||||
'or' in filter && isDefined(filter.or);
|
||||
|
||||
const isNotFilter = (filter: MetadataFilter): boolean =>
|
||||
'not' in filter && isDefined(filter.not);
|
||||
|
||||
const isImplicitAndFilter = (filter: MetadataFilter): boolean =>
|
||||
Object.keys(filter).length > 1;
|
||||
|
||||
const escapeRegExp = (str: string): string =>
|
||||
str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export const isMetadataRecordMatchingFilter = ({
|
||||
record,
|
||||
filter,
|
||||
}: {
|
||||
record: Record<string, unknown>;
|
||||
filter: MetadataFilter;
|
||||
}): boolean => {
|
||||
if (isEmptyFilter(filter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isImplicitAndFilter(filter)) {
|
||||
return Object.entries(filter).every(([key, value]) =>
|
||||
isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: { [key]: value },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isAndFilter(filter)) {
|
||||
const andValue = filter.and;
|
||||
|
||||
if (!Array.isArray(andValue)) {
|
||||
throw new Error(
|
||||
'Unexpected value for "and" filter: ' + JSON.stringify(andValue),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
andValue.length === 0 ||
|
||||
andValue.every((subFilter: MetadataFilter) =>
|
||||
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isOrFilter(filter)) {
|
||||
const orValue = filter.or;
|
||||
|
||||
if (Array.isArray(orValue)) {
|
||||
return (
|
||||
orValue.length === 0 ||
|
||||
orValue.some((subFilter: MetadataFilter) =>
|
||||
isMetadataRecordMatchingFilter({ record, filter: subFilter }),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(orValue)) {
|
||||
return isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: orValue as MetadataFilter,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unexpected value for "or" filter: ' + JSON.stringify(orValue),
|
||||
);
|
||||
}
|
||||
|
||||
if (isNotFilter(filter)) {
|
||||
const notValue = filter.not;
|
||||
|
||||
if (isObject(notValue) && isEmptyFilter(notValue as MetadataFilter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !isMetadataRecordMatchingFilter({
|
||||
record,
|
||||
filter: notValue as MetadataFilter,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.entries(filter).every(([fieldName, fieldFilter]) => {
|
||||
if (!isDefined(fieldFilter) || !isObject(fieldFilter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const recordValue = record[fieldName];
|
||||
|
||||
return isScalarValueMatchingFilter(
|
||||
recordValue,
|
||||
fieldFilter as MetadataScalarFilter,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isScalarValueMatchingFilter = (
|
||||
value: unknown,
|
||||
fieldFilter: MetadataScalarFilter,
|
||||
): boolean => {
|
||||
if ('is' in fieldFilter) {
|
||||
if (fieldFilter.is === 'NULL') {
|
||||
return !isDefined(value);
|
||||
}
|
||||
|
||||
return isDefined(value);
|
||||
}
|
||||
|
||||
if ('eq' in fieldFilter) {
|
||||
return value === fieldFilter.eq;
|
||||
}
|
||||
|
||||
if ('neq' in fieldFilter) {
|
||||
return value !== fieldFilter.neq;
|
||||
}
|
||||
|
||||
if ('in' in fieldFilter) {
|
||||
if (!Array.isArray(fieldFilter.in)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return fieldFilter.in.includes(value);
|
||||
}
|
||||
|
||||
if ('like' in fieldFilter) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = String(fieldFilter.like)
|
||||
.split('%')
|
||||
.map(escapeRegExp)
|
||||
.join('.*');
|
||||
|
||||
return new RegExp(`^${pattern}$`).test(value);
|
||||
}
|
||||
|
||||
if ('ilike' in fieldFilter) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pattern = String(fieldFilter.ilike)
|
||||
.split('%')
|
||||
.map(escapeRegExp)
|
||||
.join('.*');
|
||||
|
||||
return new RegExp(`^${pattern}$`, 'i').test(value);
|
||||
}
|
||||
|
||||
if ('gt' in fieldFilter) {
|
||||
return isDefined(value) && (value as number) > (fieldFilter.gt as number);
|
||||
}
|
||||
|
||||
if ('gte' in fieldFilter) {
|
||||
return isDefined(value) && (value as number) >= (fieldFilter.gte as number);
|
||||
}
|
||||
|
||||
if ('lt' in fieldFilter) {
|
||||
return isDefined(value) && (value as number) < (fieldFilter.lt as number);
|
||||
}
|
||||
|
||||
if ('lte' in fieldFilter) {
|
||||
return isDefined(value) && (value as number) <= (fieldFilter.lte as number);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Unsupported filter operator: ' + JSON.stringify(fieldFilter),
|
||||
);
|
||||
};
|
||||
+2
-24
@@ -1,32 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations-v2.helper';
|
||||
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
|
||||
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
|
||||
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkspaceEventEmitterResolver } from 'src/engine/workspace-event-emitter/workspace-event-emitter.resolver';
|
||||
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
SubscriptionsModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
NavigationMenuItemModule,
|
||||
],
|
||||
providers: [
|
||||
WorkspaceEventEmitter,
|
||||
WorkspaceEventEmitterService,
|
||||
WorkspaceEventEmitterResolver,
|
||||
ProcessNestedRelationsHelper,
|
||||
ProcessNestedRelationsV2Helper,
|
||||
CommonSelectFieldsHelper,
|
||||
],
|
||||
exports: [WorkspaceEventEmitter, WorkspaceEventEmitterService],
|
||||
providers: [WorkspaceEventEmitter],
|
||||
exports: [WorkspaceEventEmitter],
|
||||
})
|
||||
export class WorkspaceEventEmitterModule {}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FlatApplicationCacheMaps } from 'src/engine/core-modules/application/types/flat-application-cache-maps.type';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MetadataEventEmitter } from 'src/engine/metadata-event-emitter/metadata-event-emitter';
|
||||
import { MetadataEventEmitter } from 'src/engine/subscriptions/metadata-event/metadata-event-emitter';
|
||||
import { ALL_MANY_TO_ONE_METADATA_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant';
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user