Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506fa18295 | ||
|
|
bf410ae438 | ||
|
|
aecbc89a3f | ||
|
|
446a3923f2 | ||
|
|
4f4f723ed0 | ||
|
|
4103efcb84 | ||
|
|
d3df58046c | ||
|
|
cb6953abe3 | ||
|
|
9bc803d0c7 | ||
|
|
cd31d9e0de | ||
|
|
c0fef0be08 | ||
|
|
270069c3e3 | ||
|
|
2413af0ba9 | ||
|
|
2b5b8a8b13 | ||
|
|
7af82fb6a4 | ||
|
|
9beb1ca326 | ||
|
|
d54092b0e2 | ||
|
|
60701c2cf9 |
@@ -2651,6 +2651,7 @@ type CommandMenuItem {
|
||||
hotKeys: [String!]
|
||||
conditionalAvailabilityExpression: String
|
||||
availabilityObjectMetadataId: UUID
|
||||
pageLayoutId: UUID
|
||||
applicationId: UUID
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
@@ -4183,6 +4184,7 @@ input CreateCommandMenuItemInput {
|
||||
conditionalAvailabilityExpression: String
|
||||
availabilityObjectMetadataId: UUID
|
||||
payload: JSON
|
||||
pageLayoutId: UUID
|
||||
}
|
||||
|
||||
input UpdateCommandMenuItemInput {
|
||||
@@ -4196,6 +4198,7 @@ input UpdateCommandMenuItemInput {
|
||||
availabilityObjectMetadataId: UUID
|
||||
engineComponentKey: EngineComponentKey
|
||||
hotKeys: [String!]
|
||||
pageLayoutId: UUID
|
||||
}
|
||||
|
||||
input CreateFrontComponentInput {
|
||||
|
||||
@@ -2368,6 +2368,7 @@ export interface CommandMenuItem {
|
||||
hotKeys?: Scalars['String'][]
|
||||
conditionalAvailabilityExpression?: Scalars['String']
|
||||
availabilityObjectMetadataId?: Scalars['UUID']
|
||||
pageLayoutId?: Scalars['UUID']
|
||||
applicationId?: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
@@ -5706,6 +5707,7 @@ export interface CommandMenuItemGenqlSelection{
|
||||
hotKeys?: boolean | number
|
||||
conditionalAvailabilityExpression?: boolean | number
|
||||
availabilityObjectMetadataId?: boolean | number
|
||||
pageLayoutId?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
@@ -6742,9 +6744,9 @@ update: UpdateLogicFunctionFromSourceInputUpdates}
|
||||
|
||||
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
|
||||
|
||||
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null)}
|
||||
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null),pageLayoutId?: (Scalars['UUID'] | null)}
|
||||
|
||||
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
|
||||
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null),pageLayoutId?: (Scalars['UUID'] | null)}
|
||||
|
||||
export interface CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
|
||||
|
||||
|
||||
@@ -5334,6 +5334,9 @@ export default {
|
||||
"availabilityObjectMetadataId": [
|
||||
3
|
||||
],
|
||||
"pageLayoutId": [
|
||||
3
|
||||
],
|
||||
"applicationId": [
|
||||
3
|
||||
],
|
||||
@@ -10666,6 +10669,9 @@ export default {
|
||||
"payload": [
|
||||
15
|
||||
],
|
||||
"pageLayoutId": [
|
||||
3
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
@@ -10701,6 +10707,9 @@ export default {
|
||||
"hotKeys": [
|
||||
1
|
||||
],
|
||||
"pageLayoutId": [
|
||||
3
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@ import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useRes
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { AnalyticsType } from '~/generated-metadata/graphql';
|
||||
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
|
||||
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
|
||||
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
import { getPageTitleFromPath } from '~/utils/title-utils';
|
||||
@@ -146,10 +148,15 @@ export const PageChangeEffect = () => {
|
||||
if (!previousLocation || previousLocation !== location.pathname) {
|
||||
setPreviousLocation(location.pathname);
|
||||
executeTasksOnAnyLocationChange();
|
||||
} else {
|
||||
return;
|
||||
|
||||
const newPageLayoutId = getPageLayoutIdForLocation({
|
||||
location,
|
||||
store,
|
||||
});
|
||||
|
||||
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
|
||||
}
|
||||
}, [location, previousLocation, executeTasksOnAnyLocationChange]);
|
||||
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
|
||||
|
||||
useEffect(() => {
|
||||
initializeQueryParamState();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type getDefaultStore } from 'jotai';
|
||||
import { type Location, matchPath } from 'react-router-dom';
|
||||
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
|
||||
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
|
||||
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
|
||||
|
||||
const DASHBOARD_NAME_SINGULAR = CoreObjectNameSingular.Dashboard;
|
||||
|
||||
export const getPageLayoutIdForLocation = ({
|
||||
location,
|
||||
store,
|
||||
}: {
|
||||
location: Location;
|
||||
store: ReturnType<typeof getDefaultStore>;
|
||||
}): string | null => {
|
||||
const recordShowMatch = matchPath(AppPath.RecordShowPage, location.pathname);
|
||||
|
||||
if (isDefined(recordShowMatch?.params.objectNameSingular)) {
|
||||
const objectNameSingular = recordShowMatch.params.objectNameSingular;
|
||||
|
||||
if (objectNameSingular === DASHBOARD_NAME_SINGULAR) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectMetadataItem = store.get(
|
||||
objectMetadataItemFamilySelector.selectorFamily({
|
||||
objectName: objectNameSingular,
|
||||
objectNameType: 'singular',
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isDefined(objectMetadataItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recordPageLayout = store.get(
|
||||
recordPageLayoutByObjectMetadataIdFamilySelector.selectorFamily({
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
}),
|
||||
);
|
||||
|
||||
return isDefined(recordPageLayout)
|
||||
? recordPageLayout.id
|
||||
: getDefaultRecordPageLayoutId({
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
});
|
||||
}
|
||||
|
||||
const pageLayoutMatch = matchPath(AppPath.PageLayoutPage, location.pathname);
|
||||
|
||||
if (isDefined(pageLayoutMatch?.params.pageLayoutId)) {
|
||||
return pageLayoutMatch.params.pageLayoutId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
+5
-1
@@ -5,7 +5,9 @@ import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/compon
|
||||
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
|
||||
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
|
||||
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useStore } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
@@ -22,6 +24,7 @@ export const StandalonePageCommandMenu = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const commandMenuContextApi = useMemo<CommandMenuContextApi>(() => {
|
||||
@@ -83,6 +86,7 @@ export const StandalonePageCommandMenu = () => {
|
||||
item.availabilityType !==
|
||||
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
|
||||
)
|
||||
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
|
||||
.filter((item) =>
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
item.conditionalAvailabilityExpression,
|
||||
@@ -92,7 +96,7 @@ export const StandalonePageCommandMenu = () => {
|
||||
.sort(
|
||||
(firstItem, secondItem) => firstItem.position - secondItem.position,
|
||||
);
|
||||
}, [commandMenuItems, commandMenuContextApi]);
|
||||
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
|
||||
|
||||
return (
|
||||
<CommandMenuContext.Provider
|
||||
|
||||
+5
-1
@@ -4,7 +4,9 @@ import {
|
||||
} from '@/command-menu-item/contexts/CommandMenuContext';
|
||||
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
|
||||
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
|
||||
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
|
||||
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
|
||||
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useMemo } from 'react';
|
||||
import { type CommandMenuContextApi } from 'twenty-shared/types';
|
||||
@@ -24,6 +26,7 @@ export const CommandMenuContextProviderContent = ({
|
||||
commandMenuContextApi,
|
||||
}: CommandMenuContextProviderContentProps) => {
|
||||
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
|
||||
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
|
||||
|
||||
const filteredCommandMenuItems = useMemo(() => {
|
||||
const currentObjectMetadataItemId =
|
||||
@@ -34,6 +37,7 @@ export const CommandMenuContextProviderContent = ({
|
||||
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
|
||||
)
|
||||
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
|
||||
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
|
||||
.filter((item) =>
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
item.conditionalAvailabilityExpression,
|
||||
@@ -43,7 +47,7 @@ export const CommandMenuContextProviderContent = ({
|
||||
.sort(
|
||||
(firstItem, secondItem) => firstItem.position - secondItem.position,
|
||||
);
|
||||
}, [commandMenuItems, commandMenuContextApi]);
|
||||
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
|
||||
|
||||
return (
|
||||
<CommandMenuContext.Provider
|
||||
|
||||
+5
-24
@@ -3,10 +3,7 @@ import { Suspense, lazy } from 'react';
|
||||
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
|
||||
import { CommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/CommandComponentInstanceContext';
|
||||
import { isHeadlessFrontComponentCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessFrontComponentCommandContextApi';
|
||||
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
const FrontComponentRenderer = lazy(() =>
|
||||
import('@/front-components/components/FrontComponentRenderer').then(
|
||||
@@ -27,34 +24,18 @@ export const HeadlessFrontComponentRendererEngineCommand = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const objectNameSingular = context.objectMetadataItem?.nameSingular;
|
||||
|
||||
const recordId =
|
||||
context.selectedRecords.length === 1
|
||||
? context.selectedRecords[0].id
|
||||
: undefined;
|
||||
|
||||
// TODO: Remove layout rendering provider once we have refactored FrontComponentRenderer to have one headless renderer and a standard renderer
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LayoutRenderingProvider
|
||||
value={{
|
||||
targetRecordIdentifier:
|
||||
isDefined(objectNameSingular) && isDefined(recordId)
|
||||
? {
|
||||
id: recordId,
|
||||
targetObjectNameSingular: objectNameSingular,
|
||||
}
|
||||
: undefined,
|
||||
layoutType: PageLayoutType.DASHBOARD,
|
||||
isInSidePanel: false,
|
||||
}}
|
||||
>
|
||||
<FrontComponentRenderer
|
||||
frontComponentId={context.frontComponentId}
|
||||
commandMenuItemId={commandMenuItemId}
|
||||
/>
|
||||
</LayoutRenderingProvider>
|
||||
<FrontComponentRenderer
|
||||
frontComponentId={context.frontComponentId}
|
||||
commandMenuItemId={commandMenuItemId}
|
||||
recordId={recordId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-117
@@ -9,8 +9,6 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const mockFindOneWorkflowVersion = jest.fn();
|
||||
const mockEnqueueWarningSnackBar = jest.fn();
|
||||
const mockBuildTriggerWorkflowVersionPayloads = jest.fn();
|
||||
|
||||
jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
|
||||
useLazyFindOneRecord: () => ({
|
||||
@@ -18,20 +16,6 @@ jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({
|
||||
enqueueWarningSnackBar: mockEnqueueWarningSnackBar,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads',
|
||||
() => ({
|
||||
buildTriggerWorkflowVersionPayloads: (...args: unknown[]) =>
|
||||
mockBuildTriggerWorkflowVersionPayloads(...args),
|
||||
}),
|
||||
);
|
||||
|
||||
const getWrapper =
|
||||
(store = createStore()) =>
|
||||
({ children }: { children: ReactNode }) => (
|
||||
@@ -58,7 +42,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return enriched context API with workflow info and payloads', async () => {
|
||||
it('should return enriched context with workflow metadata', async () => {
|
||||
const store = createStore();
|
||||
const wrapper = getWrapper(store);
|
||||
|
||||
@@ -75,9 +59,6 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
|
||||
},
|
||||
);
|
||||
|
||||
const expectedPayloads = [{ recordId: 'rec-1' }];
|
||||
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue(expectedPayloads);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
|
||||
@@ -94,7 +75,8 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
|
||||
{
|
||||
headlessEngineCommandContextApi,
|
||||
workflowVersionId: 'wf-version-1',
|
||||
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
availabilityObjectMetadataId: 'obj-1',
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -103,7 +85,9 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
|
||||
...headlessEngineCommandContextApi,
|
||||
workflowId: 'workflow-1',
|
||||
workflowVersionId: 'wf-version-1',
|
||||
payloads: expectedPayloads,
|
||||
trigger: { type: 'MANUAL' },
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
availabilityObjectMetadataId: 'obj-1',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,99 +118,4 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
|
||||
|
||||
expect(enrichedResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for RECORD_SELECTION type when payloads are empty', async () => {
|
||||
const store = createStore();
|
||||
const wrapper = getWrapper(store);
|
||||
|
||||
const workflowVersionRecord = {
|
||||
id: 'wf-version-1',
|
||||
workflowId: 'workflow-1',
|
||||
trigger: { type: 'MANUAL' },
|
||||
__typename: 'WorkflowVersion' as const,
|
||||
};
|
||||
|
||||
mockFindOneWorkflowVersion.mockImplementation(
|
||||
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
|
||||
onCompleted(workflowVersionRecord);
|
||||
},
|
||||
);
|
||||
|
||||
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([]);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
let enrichedResult: unknown;
|
||||
|
||||
await act(async () => {
|
||||
enrichedResult =
|
||||
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
|
||||
{
|
||||
headlessEngineCommandContextApi: buildBaseContextApi(),
|
||||
workflowVersionId: 'wf-version-1',
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(enrichedResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show warning snackbar when selected records exceed QUERY_MAX_RECORDS', async () => {
|
||||
const store = createStore();
|
||||
const wrapper = getWrapper(store);
|
||||
|
||||
const workflowVersionRecord = {
|
||||
id: 'wf-version-1',
|
||||
workflowId: 'workflow-1',
|
||||
trigger: { type: 'MANUAL' },
|
||||
__typename: 'WorkflowVersion' as const,
|
||||
};
|
||||
|
||||
mockFindOneWorkflowVersion.mockImplementation(
|
||||
async ({ onCompleted }: { onCompleted: (data: unknown) => void }) => {
|
||||
onCompleted(workflowVersionRecord);
|
||||
},
|
||||
);
|
||||
|
||||
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue([
|
||||
{ recordId: 'rec-1' },
|
||||
]);
|
||||
|
||||
const selectedRecordIds = Array.from({ length: 201 }, (_, index) =>
|
||||
String(index),
|
||||
);
|
||||
|
||||
const headlessEngineCommandContextApi = buildBaseContextApi({
|
||||
targetedRecordsRule: { mode: 'selection', selectedRecordIds },
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
|
||||
{
|
||||
headlessEngineCommandContextApi,
|
||||
workflowVersionId: 'wf-version-1',
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockEnqueueWarningSnackBar).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: {
|
||||
dedupeKey: 'workflow-manual-trigger-selection-limit',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -84,7 +84,8 @@ describe('useMountCommand', () => {
|
||||
...baseContextApi,
|
||||
workflowId: 'workflow-1',
|
||||
workflowVersionId: 'wf-version-1',
|
||||
payloads: [{ recordId: 'rec-1' }],
|
||||
trigger: { type: 'MANUAL' },
|
||||
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
|
||||
};
|
||||
mockEnrichFn.mockResolvedValue(enrichedState);
|
||||
|
||||
|
||||
+8
-73
@@ -4,27 +4,18 @@ import {
|
||||
type HeadlessCommandContextApi,
|
||||
type HeadlessEngineCommandContextApi,
|
||||
} from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
|
||||
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { type WorkflowVersion } from '@/workflow/types/Workflow';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useStore } from 'jotai';
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
import {
|
||||
CommandMenuItemAvailabilityType as CommandMenuItemAvailabilityTypeEnum,
|
||||
type CommandMenuItemAvailabilityType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
|
||||
|
||||
type WorkflowVersionRecord = Pick<
|
||||
WorkflowVersion,
|
||||
'id' | 'workflowId' | 'trigger' | '__typename'
|
||||
>;
|
||||
|
||||
type BuildTriggerWorkflowVersionCommandStateParams = {
|
||||
type EnrichParams = {
|
||||
headlessEngineCommandContextApi: HeadlessEngineCommandContextApi;
|
||||
workflowVersionId: string;
|
||||
availabilityType: CommandMenuItemAvailabilityType;
|
||||
@@ -33,9 +24,6 @@ type BuildTriggerWorkflowVersionCommandStateParams = {
|
||||
|
||||
export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation =
|
||||
() => {
|
||||
const store = useStore();
|
||||
const { enqueueWarningSnackBar } = useSnackBar();
|
||||
|
||||
const { findOneRecord: findOneWorkflowVersion } =
|
||||
useLazyFindOneRecord<WorkflowVersionRecord>({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
@@ -65,76 +53,23 @@ export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInforma
|
||||
workflowVersionId,
|
||||
availabilityType,
|
||||
availabilityObjectMetadataId,
|
||||
}: BuildTriggerWorkflowVersionCommandStateParams): Promise<
|
||||
HeadlessCommandContextApi | undefined
|
||||
> => {
|
||||
}: EnrichParams): Promise<HeadlessCommandContextApi | undefined> => {
|
||||
const workflowVersion = await fetchWorkflowVersion(workflowVersionId);
|
||||
|
||||
if (!isDefined(workflowVersion)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
headlessEngineCommandContextApi.targetedRecordsRule.mode ===
|
||||
'exclusion'
|
||||
) {
|
||||
enqueueWarningSnackBar({
|
||||
message: t`Running workflows on all records is not yet supported. Please select records manually.`,
|
||||
options: {
|
||||
dedupeKey: 'workflow-manual-trigger-select-all-not-supported',
|
||||
},
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selectedRecordIds =
|
||||
headlessEngineCommandContextApi.targetedRecordsRule
|
||||
.selectedRecordIds;
|
||||
|
||||
if (selectedRecordIds.length > QUERY_MAX_RECORDS) {
|
||||
const selectedCountFormatted =
|
||||
selectedRecordIds.length.toLocaleString();
|
||||
|
||||
const limitFormatted = QUERY_MAX_RECORDS.toLocaleString();
|
||||
|
||||
enqueueWarningSnackBar({
|
||||
message: t`You selected ${selectedCountFormatted} records but manual triggers can run on at most ${limitFormatted} records at once. Only the first ${limitFormatted} records will be processed.`,
|
||||
options: {
|
||||
dedupeKey: 'workflow-manual-trigger-selection-limit',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const objectMetadataItems = store.get(
|
||||
objectMetadataItemsSelector.atom,
|
||||
);
|
||||
|
||||
const payloads = buildTriggerWorkflowVersionPayloads({
|
||||
store,
|
||||
trigger: workflowVersion.trigger,
|
||||
availabilityType,
|
||||
availabilityObjectMetadataId,
|
||||
objectMetadataItems,
|
||||
selectedRecordIds,
|
||||
});
|
||||
|
||||
if (
|
||||
availabilityType ===
|
||||
CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION &&
|
||||
!isNonEmptyArray(payloads)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...headlessEngineCommandContextApi,
|
||||
workflowId: workflowVersion.workflowId,
|
||||
workflowVersionId: workflowVersion.id,
|
||||
payloads,
|
||||
trigger: workflowVersion.trigger,
|
||||
availabilityType,
|
||||
availabilityObjectMetadataId,
|
||||
};
|
||||
},
|
||||
[store, fetchWorkflowVersion, enqueueWarningSnackBar],
|
||||
[fetchWorkflowVersion],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
+47
-7
@@ -1,23 +1,63 @@
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
|
||||
import { buildTriggerWorkflowVersionPayloads } from '@/command-menu-item/engine-command/utils/buildTriggerWorkflowVersionPayloads';
|
||||
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
|
||||
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
CoreObjectNameSingular,
|
||||
type RecordGqlOperationFilter,
|
||||
} from 'twenty-shared/types';
|
||||
import { isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
export const TriggerWorkflowVersionEngineCommand = () => {
|
||||
const mountedCommandState = useHeadlessCommandContextApi();
|
||||
const store = useStore();
|
||||
|
||||
if (!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)) {
|
||||
throw new Error(
|
||||
'TriggerWorkflowVersionEngineCommand requires a workflow trigger context',
|
||||
);
|
||||
}
|
||||
|
||||
const noMatchFilter: RecordGqlOperationFilter = { id: { in: [] } };
|
||||
|
||||
const { fetchAllRecords } = useLazyFetchAllRecords({
|
||||
objectNameSingular:
|
||||
mountedCommandState.objectMetadataItem?.nameSingular ??
|
||||
CoreObjectNameSingular.Person,
|
||||
filter: mountedCommandState.graphqlFilter ?? noMatchFilter,
|
||||
limit: DEFAULT_QUERY_PAGE_SIZE,
|
||||
});
|
||||
|
||||
const { runWorkflowVersion } = useRunWorkflowVersion();
|
||||
|
||||
const execute = useCallback(async () => {
|
||||
if (
|
||||
!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)
|
||||
) {
|
||||
return;
|
||||
let selectedRecords: ObjectRecord[];
|
||||
|
||||
if (mountedCommandState.targetedRecordsRule.mode === 'selection') {
|
||||
selectedRecords = mountedCommandState.selectedRecords;
|
||||
} else {
|
||||
selectedRecords = await fetchAllRecords();
|
||||
}
|
||||
|
||||
if (!isNonEmptyArray(mountedCommandState.payloads)) {
|
||||
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
|
||||
|
||||
const payloads = buildTriggerWorkflowVersionPayloads({
|
||||
trigger: mountedCommandState.trigger,
|
||||
availabilityType: mountedCommandState.availabilityType,
|
||||
availabilityObjectMetadataId:
|
||||
mountedCommandState.availabilityObjectMetadataId,
|
||||
objectMetadataItems,
|
||||
selectedRecords,
|
||||
});
|
||||
|
||||
if (!isNonEmptyArray(payloads)) {
|
||||
await runWorkflowVersion({
|
||||
workflowId: mountedCommandState.workflowId,
|
||||
workflowVersionId: mountedCommandState.workflowVersionId,
|
||||
@@ -26,14 +66,14 @@ export const TriggerWorkflowVersionEngineCommand = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const payload of mountedCommandState.payloads) {
|
||||
for (const payload of payloads) {
|
||||
await runWorkflowVersion({
|
||||
workflowId: mountedCommandState.workflowId,
|
||||
workflowVersionId: mountedCommandState.workflowVersionId,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}, [runWorkflowVersion, mountedCommandState]);
|
||||
}, [mountedCommandState, fetchAllRecords, runWorkflowVersion, store]);
|
||||
|
||||
return <HeadlessEngineCommandWrapperEffect execute={execute} />;
|
||||
};
|
||||
|
||||
+5
-1
@@ -1,11 +1,13 @@
|
||||
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import {
|
||||
type Nullable,
|
||||
type RecordGqlOperationFilter,
|
||||
} from 'twenty-shared/types';
|
||||
import {
|
||||
type CommandMenuItemAvailabilityType,
|
||||
type EngineComponentKey,
|
||||
type CommandMenuItemPayload,
|
||||
} from '~/generated-metadata/graphql';
|
||||
@@ -31,7 +33,9 @@ export type HeadlessTriggerWorkflowVersionCommandContextApi =
|
||||
HeadlessEngineCommandContextApi & {
|
||||
workflowId: string;
|
||||
workflowVersionId: string;
|
||||
payloads: Record<string, any>[];
|
||||
trigger: WorkflowTrigger | null;
|
||||
availabilityType: CommandMenuItemAvailabilityType;
|
||||
availabilityObjectMetadataId?: string | null;
|
||||
};
|
||||
|
||||
export type HeadlessCommandContextApi =
|
||||
|
||||
+6
-2
@@ -1,6 +1,9 @@
|
||||
import { type HeadlessCommandContextApi } from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
|
||||
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
|
||||
import { EngineComponentKey } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
CommandMenuItemAvailabilityType,
|
||||
EngineComponentKey,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const baseContextApi: HeadlessCommandContextApi = {
|
||||
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
|
||||
@@ -20,7 +23,8 @@ describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
|
||||
...baseContextApi,
|
||||
workflowId: 'wf-1',
|
||||
workflowVersionId: 'wfv-1',
|
||||
payloads: [],
|
||||
trigger: null,
|
||||
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
|
||||
};
|
||||
|
||||
expect(
|
||||
|
||||
+5
-29
@@ -1,10 +1,7 @@
|
||||
import type { Store } from 'jotai/vanilla/store';
|
||||
|
||||
import { isBulkRecordsManualTrigger } from '@/command-menu-item/record/utils/isBulkRecordsManualTrigger';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
|
||||
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
type CommandMenuItemAvailabilityType,
|
||||
@@ -12,44 +9,31 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const buildTriggerWorkflowVersionPayloads = ({
|
||||
store,
|
||||
trigger,
|
||||
availabilityType,
|
||||
availabilityObjectMetadataId,
|
||||
objectMetadataItems,
|
||||
selectedRecordIds,
|
||||
selectedRecords,
|
||||
}: {
|
||||
store: Store;
|
||||
trigger: WorkflowTrigger | null;
|
||||
availabilityType: CommandMenuItemAvailabilityType;
|
||||
availabilityObjectMetadataId?: string | null;
|
||||
objectMetadataItems: EnrichedObjectMetadataItem[];
|
||||
selectedRecordIds: string[];
|
||||
selectedRecords: ObjectRecord[];
|
||||
}): Record<string, any>[] => {
|
||||
const payloads: Record<string, any>[] = [];
|
||||
|
||||
switch (availabilityType) {
|
||||
case CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION: {
|
||||
if (selectedRecordIds.length === 0) {
|
||||
if (selectedRecords.length === 0) {
|
||||
return payloads;
|
||||
}
|
||||
|
||||
const limitedSelectedRecordIds = selectedRecordIds.slice(
|
||||
0,
|
||||
QUERY_MAX_RECORDS,
|
||||
);
|
||||
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(metadata) => metadata.id === availabilityObjectMetadataId,
|
||||
);
|
||||
|
||||
if (isDefined(trigger) && isBulkRecordsManualTrigger(trigger)) {
|
||||
const selectedRecords = limitedSelectedRecordIds
|
||||
.map((recordId) =>
|
||||
store.get(recordStoreFamilyState.atomFamily(recordId)),
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
if (isDefined(objectMetadataItem)) {
|
||||
payloads.push({
|
||||
[objectMetadataItem.namePlural]: selectedRecords,
|
||||
@@ -59,15 +43,7 @@ export const buildTriggerWorkflowVersionPayloads = ({
|
||||
return payloads;
|
||||
}
|
||||
|
||||
for (const selectedRecordId of limitedSelectedRecordIds) {
|
||||
const selectedRecord = store.get(
|
||||
recordStoreFamilyState.atomFamily(selectedRecordId),
|
||||
);
|
||||
|
||||
if (!isDefined(selectedRecord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const selectedRecord of selectedRecords) {
|
||||
payloads.push(selectedRecord);
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -28,5 +28,6 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
|
||||
conditionalAvailabilityExpression
|
||||
availabilityType
|
||||
availabilityObjectMetadataId
|
||||
pageLayoutId
|
||||
}
|
||||
`;
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
|
||||
|
||||
export const doesCommandMenuItemMatchPageLayoutId =
|
||||
(pageLayoutId: string | null) => (item: CommandMenuItemFieldsFragment) =>
|
||||
!isDefined(item.pageLayoutId) || item.pageLayoutId === pageLayoutId;
|
||||
+7
-1
@@ -18,11 +18,13 @@ import { FindOneFrontComponentDocument } from '~/generated-metadata/graphql';
|
||||
type FrontComponentRendererProps = {
|
||||
frontComponentId: string;
|
||||
commandMenuItemId?: string;
|
||||
recordId?: string;
|
||||
};
|
||||
|
||||
export const FrontComponentRenderer = ({
|
||||
frontComponentId,
|
||||
commandMenuItemId,
|
||||
recordId,
|
||||
}: FrontComponentRendererProps) => {
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
@@ -33,7 +35,11 @@ export const FrontComponentRenderer = ({
|
||||
);
|
||||
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext({ frontComponentId, commandMenuItemId });
|
||||
useFrontComponentExecutionContext({
|
||||
frontComponentId,
|
||||
commandMenuItemId,
|
||||
recordId,
|
||||
});
|
||||
|
||||
const handleError = useCallback(
|
||||
(error?: Error) => {
|
||||
|
||||
+2
-13
@@ -17,9 +17,6 @@ const mockCloseSidePanelMenu = jest.fn();
|
||||
const mockSetCommandMenuItemProgress = jest.fn();
|
||||
|
||||
let mockCurrentUser: { id: string } | null = { id: 'user-123' };
|
||||
let mockTargetRecordIdentifier: { id: string } | undefined = {
|
||||
id: 'record-456',
|
||||
};
|
||||
|
||||
jest.mock('~/hooks/useNavigateApp', () => ({
|
||||
useNavigateApp: () => mockNavigateApp,
|
||||
@@ -86,12 +83,6 @@ jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState', () => ({
|
||||
useSetAtomFamilyState: () => mockSetCommandMenuItemProgress,
|
||||
}));
|
||||
|
||||
jest.mock('@/ui/layout/contexts/LayoutRenderingContext', () => ({
|
||||
useLayoutRenderingContext: () => ({
|
||||
targetRecordIdentifier: mockTargetRecordIdentifier,
|
||||
}),
|
||||
}));
|
||||
|
||||
const FRONT_COMPONENT_ID = 'fc-test-id';
|
||||
const COMMAND_MENU_ITEM_ID = 'cmd-item-1';
|
||||
|
||||
@@ -99,7 +90,6 @@ describe('useFrontComponentExecutionContext', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockCurrentUser = { id: 'user-123' };
|
||||
mockTargetRecordIdentifier = { id: 'record-456' };
|
||||
});
|
||||
|
||||
describe('executionContext', () => {
|
||||
@@ -107,6 +97,7 @@ describe('useFrontComponentExecutionContext', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFrontComponentExecutionContext({
|
||||
frontComponentId: FRONT_COMPONENT_ID,
|
||||
recordId: 'record-456',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -129,9 +120,7 @@ describe('useFrontComponentExecutionContext', () => {
|
||||
expect(result.current.executionContext.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null recordId when no target record', () => {
|
||||
mockTargetRecordIdentifier = undefined;
|
||||
|
||||
it('should return null recordId when no recordId provided', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useFrontComponentExecutionContext({
|
||||
frontComponentId: FRONT_COMPONENT_ID,
|
||||
|
||||
+3
-4
@@ -14,7 +14,6 @@ import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
@@ -24,9 +23,11 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
export const useFrontComponentExecutionContext = ({
|
||||
frontComponentId,
|
||||
commandMenuItemId,
|
||||
recordId,
|
||||
}: {
|
||||
frontComponentId: string;
|
||||
commandMenuItemId?: string;
|
||||
recordId?: string;
|
||||
}): {
|
||||
executionContext: FrontComponentExecutionContext;
|
||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||
@@ -123,12 +124,10 @@ export const useFrontComponentExecutionContext = ({
|
||||
}
|
||||
};
|
||||
|
||||
const { targetRecordIdentifier } = useLayoutRenderingContext();
|
||||
|
||||
const executionContext: FrontComponentExecutionContext = {
|
||||
frontComponentId,
|
||||
userId: currentUser?.id ?? null,
|
||||
recordId: targetRecordIdentifier?.id ?? null,
|
||||
recordId: recordId ?? null,
|
||||
};
|
||||
|
||||
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
|
||||
|
||||
+27
-8
@@ -3,14 +3,20 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { ViewFieldsSearchDropdownSection } from '@/views/components/ViewFieldsSearchDropdownSection';
|
||||
import { ViewFieldsVisibleDropdownSection } from '@/views/components/ViewFieldsVisibleDropdownSection';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useState } from 'react';
|
||||
import { IconChevronLeft, IconEyeOff } from 'twenty-ui/display';
|
||||
import { MenuItemNavigate } from 'twenty-ui/navigation';
|
||||
|
||||
export const ObjectOptionsDropdownFieldsContent = () => {
|
||||
const { t } = useLingui();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
|
||||
const { onContentChange, resetContent } = useObjectOptionsDropdown();
|
||||
|
||||
return (
|
||||
@@ -25,15 +31,28 @@ export const ObjectOptionsDropdownFieldsContent = () => {
|
||||
>
|
||||
{t`Fields`}
|
||||
</DropdownMenuHeader>
|
||||
<ViewFieldsVisibleDropdownSection />
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchInput}
|
||||
placeholder={t`Search fields`}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
<MenuItemNavigate
|
||||
onClick={() => onContentChange('hiddenFields')}
|
||||
LeftIcon={IconEyeOff}
|
||||
text={t`Hidden Fields`}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
{isNonEmptyString(searchInput) ? (
|
||||
<ViewFieldsSearchDropdownSection searchInput={searchInput} />
|
||||
) : (
|
||||
<>
|
||||
<ViewFieldsVisibleDropdownSection />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
<MenuItemNavigate
|
||||
onClick={() => onContentChange('hiddenFields')}
|
||||
LeftIcon={IconEyeOff}
|
||||
text={t`Hidden Fields`}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
)}
|
||||
</DropdownContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -95,7 +95,9 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
|
||||
|
||||
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
|
||||
|
||||
store.set(currentPageLayoutIdState.atom, value ? pageLayoutId : null);
|
||||
if (value) {
|
||||
store.set(currentPageLayoutIdState.atom, pageLayoutId);
|
||||
}
|
||||
},
|
||||
[
|
||||
isDashboardInEditModeState,
|
||||
|
||||
+6
-1
@@ -7,6 +7,7 @@ import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutIn
|
||||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
|
||||
import { isWidgetConfigurationOfType } from '@/side-panel/pages/page-layout/utils/isWidgetConfigurationOfType';
|
||||
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
|
||||
const StyledContainer = styled.div<{ isInEditMode: boolean }>`
|
||||
height: 100%;
|
||||
@@ -29,6 +30,7 @@ export const FrontComponentWidgetRenderer = ({
|
||||
widget,
|
||||
}: FrontComponentWidgetRendererProps) => {
|
||||
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
|
||||
const { targetRecordIdentifier } = useLayoutRenderingContext();
|
||||
|
||||
const configuration = widget.configuration;
|
||||
|
||||
@@ -44,7 +46,10 @@ export const FrontComponentWidgetRenderer = ({
|
||||
return (
|
||||
<StyledContainer isInEditMode={isPageLayoutInEditMode}>
|
||||
<Suspense fallback={null}>
|
||||
<FrontComponentRenderer frontComponentId={frontComponentId} />
|
||||
<FrontComponentRenderer
|
||||
frontComponentId={frontComponentId}
|
||||
recordId={targetRecordIdentifier?.id}
|
||||
/>
|
||||
</Suspense>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
+6
-19
@@ -2,10 +2,8 @@ import { Suspense, lazy } from 'react';
|
||||
|
||||
import { viewableFrontComponentIdComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentIdComponentState';
|
||||
import { viewableFrontComponentRecordContextComponentState } from '@/side-panel/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
|
||||
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { PageLayoutType } from '~/generated-metadata/graphql';
|
||||
|
||||
const FrontComponentRenderer = lazy(() =>
|
||||
import('@/front-components/components/FrontComponentRenderer').then(
|
||||
@@ -27,22 +25,11 @@ export const SidePanelFrontComponentPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutRenderingProvider
|
||||
value={{
|
||||
targetRecordIdentifier: isDefined(viewableFrontComponentRecordContext)
|
||||
? {
|
||||
id: viewableFrontComponentRecordContext.recordId,
|
||||
targetObjectNameSingular:
|
||||
viewableFrontComponentRecordContext.objectNameSingular,
|
||||
}
|
||||
: undefined,
|
||||
layoutType: PageLayoutType.DASHBOARD,
|
||||
isInSidePanel: true,
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<FrontComponentRenderer frontComponentId={viewableFrontComponentId} />
|
||||
</Suspense>
|
||||
</LayoutRenderingProvider>
|
||||
<Suspense fallback={null}>
|
||||
<FrontComponentRenderer
|
||||
frontComponentId={viewableFrontComponentId}
|
||||
recordId={viewableFrontComponentRecordContext?.recordId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
+36
@@ -293,6 +293,42 @@ describe('useAddressAutocomplete', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should close dropdown and not throw when autocomplete query fails', async () => {
|
||||
mockGetPlaceAutocompleteData.mockRejectedValue(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAddressAutocomplete());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.getAutocompletePlaceData('ü', 'token123');
|
||||
});
|
||||
|
||||
expect(mockCloseDropdown).toHaveBeenCalled();
|
||||
expect(mockOpenDropdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should close dropdown and not throw when place details query fails', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
|
||||
mockGetPlaceDetailsData.mockRejectedValue(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAddressAutocomplete(mockOnChange));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.autoFillInputsFromPlaceDetails(
|
||||
'place123',
|
||||
'token123',
|
||||
'123 Main St',
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockCloseDropdown).toHaveBeenCalled();
|
||||
expect(mockOnChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle address autocomplete with country and isFieldCity parameters', async () => {
|
||||
mockGetPlaceAutocompleteData.mockResolvedValue([
|
||||
{ text: 'Boston, MA', placeId: 'place1' },
|
||||
|
||||
@@ -47,12 +47,20 @@ export const useAddressAutocomplete = (
|
||||
country?: string,
|
||||
isFieldCity?: boolean,
|
||||
) => {
|
||||
const placeAutocompleteData = await getPlaceAutocompleteData(
|
||||
address,
|
||||
token,
|
||||
country,
|
||||
isFieldCity,
|
||||
);
|
||||
let placeAutocompleteData: PlaceAutocompleteResult[] | undefined;
|
||||
|
||||
try {
|
||||
placeAutocompleteData = await getPlaceAutocompleteData(
|
||||
address,
|
||||
token,
|
||||
country,
|
||||
isFieldCity,
|
||||
);
|
||||
} catch {
|
||||
closeDropdownOfAutocomplete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newData = placeAutocompleteData?.map((data) => ({
|
||||
text: data.text,
|
||||
@@ -76,7 +84,16 @@ export const useAddressAutocomplete = (
|
||||
addressStreet1?: string,
|
||||
internalValue?: FieldAddressDraftValue,
|
||||
) => {
|
||||
const placeData = await getPlaceDetailsData(placeId, token);
|
||||
let placeData: Awaited<ReturnType<typeof getPlaceDetailsData>>;
|
||||
|
||||
try {
|
||||
placeData = await getPlaceDetailsData(placeId, token);
|
||||
} catch {
|
||||
closeDropdownOfAutocomplete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const countryName = findCountryNameByCountryCode(placeData?.country);
|
||||
|
||||
const updatedAddress = {
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
|
||||
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
|
||||
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
|
||||
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
|
||||
import { useChangeRecordFieldVisibility } from '@/object-record/record-field/hooks/useChangeRecordFieldVisibility';
|
||||
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { ViewType } from '@/views/types/ViewType';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useContext } from 'react';
|
||||
import { IconEye, IconEyeOff, useIcons } from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
type ViewFieldsSearchDropdownSectionProps = {
|
||||
searchInput: string;
|
||||
};
|
||||
|
||||
export const ViewFieldsSearchDropdownSection = ({
|
||||
searchInput,
|
||||
}: ViewFieldsSearchDropdownSectionProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const { viewType, objectMetadataItem, recordIndexId } = useContext(
|
||||
ObjectOptionsDropdownContext,
|
||||
);
|
||||
|
||||
const { changeRecordFieldVisibility } =
|
||||
useChangeRecordFieldVisibility(recordIndexId);
|
||||
|
||||
const { handleBoardFieldVisibilityChange } = useObjectOptionsForBoard({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
recordBoardId: recordIndexId,
|
||||
viewBarId: recordIndexId,
|
||||
});
|
||||
|
||||
const handleChangeFieldVisibility =
|
||||
viewType === ViewType.KANBAN
|
||||
? handleBoardFieldVisibilityChange
|
||||
: changeRecordFieldVisibility;
|
||||
|
||||
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
|
||||
objectMetadataItem,
|
||||
});
|
||||
|
||||
const visibleRecordFields = useAtomComponentSelectorValue(
|
||||
visibleRecordFieldsComponentSelector,
|
||||
);
|
||||
|
||||
const visibleFieldIds = new Set(
|
||||
visibleRecordFields.map((recordField) => recordField.fieldMetadataItemId),
|
||||
);
|
||||
|
||||
const fieldMetadataItemLabelIdentifier =
|
||||
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
|
||||
|
||||
const filteredFields = activeFieldMetadataItems.filter(
|
||||
(fieldMetadataItem) => {
|
||||
return fieldMetadataItem.label
|
||||
.toLowerCase()
|
||||
.includes(searchInput.toLowerCase());
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItemsContainer>
|
||||
{filteredFields.length > 0 ? (
|
||||
filteredFields.map((fieldMetadataItem) => {
|
||||
const isVisible = visibleFieldIds.has(fieldMetadataItem.id);
|
||||
const isLabelIdentifier =
|
||||
fieldMetadataItem.id === fieldMetadataItemLabelIdentifier?.id;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={fieldMetadataItem.id}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
iconButtons={
|
||||
isLabelIdentifier
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
Icon: isVisible ? IconEyeOff : IconEye,
|
||||
onClick: () =>
|
||||
handleChangeFieldVisibility({
|
||||
fieldMetadataId: fieldMetadataItem.id,
|
||||
isVisible: !isVisible,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
text={fieldMetadataItem.label}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<MenuItem text={t`No results`} />
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
);
|
||||
};
|
||||
+162
-19
@@ -1,20 +1,38 @@
|
||||
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
|
||||
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
|
||||
import { LogicFunctionExecutionResult } from '@/logic-functions/components/LogicFunctionExecutionResult';
|
||||
import { LogicFunctionLogs } from '@/logic-functions/components/LogicFunctionLogs';
|
||||
import { LogicFunctionTestInputInitEffect } from '@/logic-functions/components/LogicFunctionTestInputInitEffect';
|
||||
import { useExecuteLogicFunction } from '@/logic-functions/hooks/useExecuteLogicFunction';
|
||||
import { useGetOneLogicFunction } from '@/logic-functions/hooks/useGetOneLogicFunction';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { TabList } from '@/ui/layout/tab-list/components/TabList';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { type WorkflowLogicFunctionAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepCmdEnterButton } from '@/workflow/workflow-steps/components/WorkflowStepCmdEnterButton';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowEditActionCodeFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields';
|
||||
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/mergeDefaultFunctionInputAndFunctionInput';
|
||||
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/setNestedValue';
|
||||
import { WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/logic-function-action/constants/WorkflowLogicFunctionActionTabListComponentId';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { useMemo } from 'react';
|
||||
import { getOutputSchemaFromValue } from 'twenty-shared/logic-function';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Callout } from 'twenty-ui/display';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
|
||||
import {
|
||||
Callout,
|
||||
IconPlayerPlay,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-ui/display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const INPUT_TAB_ID = 'input';
|
||||
const TEST_TAB_ID = 'test';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -22,6 +40,17 @@ const StyledContainer = styled.div`
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledTabListContainer = styled.div`
|
||||
background-color: ${themeCssVariables.background.secondary};
|
||||
padding-left: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledResultContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
type WorkflowEditActionLogicFunctionProps = {
|
||||
action: WorkflowLogicFunctionAction;
|
||||
actionOptions:
|
||||
@@ -46,6 +75,11 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
id: logicFunctionId,
|
||||
});
|
||||
|
||||
const activeTabId = useAtomComponentStateValue(
|
||||
activeTabIdComponentState,
|
||||
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID,
|
||||
);
|
||||
|
||||
const functionInput = useMemo(() => {
|
||||
const toolInputSchema = logicFunction?.toolInputSchema;
|
||||
|
||||
@@ -86,6 +120,34 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
500,
|
||||
);
|
||||
|
||||
const updateOutputSchemaFromTestResult = (testResult: object) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOutputSchema = getOutputSchemaFromValue(testResult);
|
||||
|
||||
updateAction({
|
||||
...action,
|
||||
settings: { ...action.settings, outputSchema: newOutputSchema },
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
executeLogicFunction,
|
||||
isExecuting,
|
||||
logicFunctionTestData,
|
||||
updateLogicFunctionInput,
|
||||
} = useExecuteLogicFunction({
|
||||
logicFunctionId,
|
||||
callback: updateOutputSchemaFromTestResult,
|
||||
});
|
||||
|
||||
const testInput = mergeDefaultFunctionInputAndFunctionInput({
|
||||
newInput: functionInput,
|
||||
oldInput: logicFunctionTestData.input,
|
||||
});
|
||||
|
||||
const handleInputChange = (value: unknown, path: string[]) => {
|
||||
const updatedFunctionInput = setNestedValue(functionInput, path, value);
|
||||
|
||||
@@ -100,34 +162,115 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestInputChange = (value: unknown, path: string[]) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTestFunctionInput = setNestedValue(testInput, path, value);
|
||||
|
||||
updateLogicFunctionInput(updatedTestFunctionInput);
|
||||
};
|
||||
|
||||
const handleTestFunction = async () => {
|
||||
if (actionOptions.readonly === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
await executeLogicFunction();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasInputFields = Object.keys(functionInput).length > 0;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: INPUT_TAB_ID,
|
||||
title: t`Input`,
|
||||
Icon: IconSettingsAutomation,
|
||||
},
|
||||
{
|
||||
id: TEST_TAB_ID,
|
||||
title: t`Test`,
|
||||
Icon: IconPlayerPlay,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogicFunctionTestInputInitEffect logicFunctionId={logicFunctionId} />
|
||||
<StyledTabListContainer>
|
||||
<TabList
|
||||
tabs={tabs}
|
||||
behaveAsLinks={false}
|
||||
componentInstanceId={
|
||||
WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID
|
||||
}
|
||||
/>
|
||||
</StyledTabListContainer>
|
||||
<WorkflowStepBody>
|
||||
<StyledContainer>
|
||||
{hasInputFields ? (
|
||||
{activeTabId === TEST_TAB_ID ? (
|
||||
<>
|
||||
<WorkflowEditActionCodeFields
|
||||
functionInput={functionInput}
|
||||
functionInput={testInput}
|
||||
onInputChange={handleTestInputChange}
|
||||
readonly={actionOptions.readonly}
|
||||
onInputChange={handleInputChange}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<Callout
|
||||
variant={'neutral'}
|
||||
title={t`No input fields for this action`}
|
||||
description={t`You can see the function logic in your application settings.`}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
<StyledResultContainer>
|
||||
<InputLabel>{t`Result`}</InputLabel>
|
||||
<LogicFunctionExecutionResult
|
||||
logicFunctionTestData={logicFunctionTestData}
|
||||
isTesting={isExecuting}
|
||||
/>
|
||||
</StyledResultContainer>
|
||||
{logicFunctionTestData.output.logs.length > 0 && (
|
||||
<StyledResultContainer>
|
||||
<LogicFunctionLogs
|
||||
componentInstanceId={`workflow-edit-action-logs-${action.id}`}
|
||||
value={isExecuting ? '' : logicFunctionTestData.output.logs}
|
||||
/>
|
||||
</StyledResultContainer>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<StyledContainer>
|
||||
{hasInputFields ? (
|
||||
<WorkflowEditActionCodeFields
|
||||
functionInput={functionInput}
|
||||
readonly={actionOptions.readonly}
|
||||
onInputChange={handleInputChange}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<Callout
|
||||
variant={'neutral'}
|
||||
title={t`No input fields for this action`}
|
||||
description={t`You can see the function logic in your application settings.`}
|
||||
/>
|
||||
)}
|
||||
</StyledContainer>
|
||||
)}
|
||||
</WorkflowStepBody>
|
||||
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
|
||||
{!actionOptions.readonly && (
|
||||
<WorkflowStepFooter
|
||||
stepId={action.id}
|
||||
additionalActions={
|
||||
activeTabId === TEST_TAB_ID
|
||||
? [
|
||||
<WorkflowStepCmdEnterButton
|
||||
title={t`Test`}
|
||||
onClick={handleTestFunction}
|
||||
disabled={isExecuting}
|
||||
/>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const WORKFLOW_LOGIC_FUNCTION_ACTION_TAB_LIST_COMPONENT_ID =
|
||||
'workflow-logic-function-action-tab-list-component-id';
|
||||
+13
@@ -61,6 +61,15 @@ describe('computeStepOutputSchema', () => {
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for LOGIC_FUNCTION step type', () => {
|
||||
const result = computeStepOutputSchema({
|
||||
step: { type: 'LOGIC_FUNCTION', settings: {} } as any,
|
||||
objectMetadataItems: [],
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DATABASE_EVENT trigger', () => {
|
||||
@@ -466,6 +475,10 @@ describe('shouldComputeOutputSchemaOnFrontend', () => {
|
||||
expect(shouldComputeOutputSchemaOnFrontend('ITERATOR')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for LOGIC_FUNCTION', () => {
|
||||
expect(shouldComputeOutputSchemaOnFrontend('LOGIC_FUNCTION')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for DATABASE_EVENT', () => {
|
||||
expect(shouldComputeOutputSchemaOnFrontend('DATABASE_EVENT')).toBe(true);
|
||||
});
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ const PERSISTED_OUTPUT_SCHEMA_TYPES = [
|
||||
'AI_AGENT',
|
||||
'CODE',
|
||||
'HTTP_REQUEST',
|
||||
'LOGIC_FUNCTION',
|
||||
'WEBHOOK',
|
||||
'ITERATOR',
|
||||
];
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { UpgradeStatusCommand } from 'src/engine/core-modules/upgrade/commands/upgrade-status.command';
|
||||
import { UpgradeModule } from 'src/engine/core-modules/upgrade/upgrade.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
@@ -86,6 +87,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
|
||||
EnterpriseKeyValidationCronCommand,
|
||||
GenerateApiKeyCommand,
|
||||
EnforceUsageCapCronCommand,
|
||||
UpgradeStatusCommand,
|
||||
],
|
||||
})
|
||||
export class DatabaseCommandModule {}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TWENTY_PREVIOUS_VERSIONS } from 'src/engine/core-modules/upgrade/consta
|
||||
import { InstanceCommandRunnerService } from 'src/engine/core-modules/upgrade/services/instance-command-runner.service';
|
||||
import { UpgradeCommandRegistryService } from 'src/engine/core-modules/upgrade/services/upgrade-command-registry.service';
|
||||
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
|
||||
import { UpgradeSequenceReaderService } from 'src/engine/core-modules/upgrade/services/upgrade-sequence-reader.service';
|
||||
import { WorkspaceVersionService } from 'src/engine/workspace-manager/workspace-version/services/workspace-version.service';
|
||||
|
||||
type RunInstanceCommandsOptions = {
|
||||
@@ -30,6 +31,7 @@ export class RunInstanceCommandsCommand extends CommandRunner {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly workspaceVersionService: WorkspaceVersionService,
|
||||
private readonly upgradeCommandRegistryService: UpgradeCommandRegistryService,
|
||||
private readonly upgradeSequenceReaderService: UpgradeSequenceReaderService,
|
||||
private readonly instanceUpgradeService: InstanceCommandRunnerService,
|
||||
private readonly upgradeMigrationService: UpgradeMigrationService,
|
||||
) {
|
||||
@@ -65,31 +67,26 @@ export class RunInstanceCommandsCommand extends CommandRunner {
|
||||
const activeOrSuspendedWorkspaceIds =
|
||||
await this.workspaceVersionService.getActiveOrSuspendedWorkspaceIds();
|
||||
|
||||
for (const {
|
||||
command,
|
||||
name,
|
||||
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedFastInstanceCommands()) {
|
||||
const result = await this.instanceUpgradeService.runFastInstanceCommand(
|
||||
{
|
||||
command,
|
||||
name,
|
||||
},
|
||||
);
|
||||
const sequence = this.upgradeSequenceReaderService.getUpgradeSequence();
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw result.error;
|
||||
for (const step of sequence) {
|
||||
if (step.kind === 'fast-instance') {
|
||||
const result =
|
||||
await this.instanceUpgradeService.runFastInstanceCommand({
|
||||
command: step.command,
|
||||
name: step.name,
|
||||
});
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw result.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.includeSlow) {
|
||||
for (const {
|
||||
command,
|
||||
name,
|
||||
} of this.upgradeCommandRegistryService.getCrossUpgradeSupportedSlowInstanceCommands()) {
|
||||
if (step.kind === 'slow-instance' && options.includeSlow) {
|
||||
const result =
|
||||
await this.instanceUpgradeService.runSlowInstanceCommand({
|
||||
command,
|
||||
name,
|
||||
command: step.command,
|
||||
name: step.name,
|
||||
skipDataMigration: activeOrSuspendedWorkspaceIds.length === 0,
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -238,6 +238,8 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac
|
||||
workflowVersionId: null,
|
||||
availabilityObjectMetadataId: null,
|
||||
availabilityObjectMetadataUniversalIdentifier: null,
|
||||
pageLayoutId: null,
|
||||
pageLayoutUniversalIdentifier: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { QueryRunner } from 'typeorm';
|
||||
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
|
||||
|
||||
@RegisteredInstanceCommand('1.23.0', 1776168404836)
|
||||
export class AddPageLayoutIdToCommandMenuItemFastInstanceCommand
|
||||
implements FastInstanceCommand
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."commandMenuItem" ADD "pageLayoutId" uuid',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("pageLayoutId", "workspaceId") ',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_8577be6253969364b6725b807b4" FOREIGN KEY ("pageLayoutId") REFERENCES "core"."pageLayout"("id") ON DELETE CASCADE ON UPDATE NO ACTION',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_8577be6253969364b6725b807b4"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "core"."commandMenuItem" DROP COLUMN "pageLayoutId"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -13,6 +13,7 @@ import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/databa
|
||||
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
|
||||
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
|
||||
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
|
||||
import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item';
|
||||
|
||||
export const INSTANCE_COMMANDS = [
|
||||
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
|
||||
@@ -28,4 +29,5 @@ export const INSTANCE_COMMANDS = [
|
||||
AddCreditBalanceToBillingCustomerFastInstanceCommand,
|
||||
DropWorkspaceVersionColumnFastInstanceCommand,
|
||||
AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand,
|
||||
AddPageLayoutIdToCommandMenuItemFastInstanceCommand,
|
||||
];
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
||||
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user