Compare commits

..
Author SHA1 Message Date
martmull 6a32032b78 Use universal identifiers in all setting paths 2026-04-16 19:53:52 +02:00
martmull ea5091f8c0 Add tabs in admin apps details 2026-04-16 15:45:47 +02:00
martmull 5f5f654602 Fix config settings 2026-04-16 15:00:39 +02:00
156 changed files with 7966 additions and 7386 deletions
@@ -2524,6 +2524,7 @@ type File {
type MarketplaceApp {
id: String!
universalIdentifier: String!
name: String!
description: String!
icon: String!
@@ -2651,7 +2652,6 @@ type CommandMenuItem {
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
@@ -4184,7 +4184,6 @@ input CreateCommandMenuItemInput {
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
payload: JSON
pageLayoutId: UUID
}
input UpdateCommandMenuItemInput {
@@ -4198,7 +4197,6 @@ input UpdateCommandMenuItemInput {
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
pageLayoutId: UUID
}
input CreateFrontComponentInput {
@@ -2235,6 +2235,7 @@ export interface File {
export interface MarketplaceApp {
id: Scalars['String']
universalIdentifier: Scalars['String']
name: Scalars['String']
description: Scalars['String']
icon: Scalars['String']
@@ -2368,7 +2369,6 @@ export interface CommandMenuItem {
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -5565,6 +5565,7 @@ export interface FileGenqlSelection{
export interface MarketplaceAppGenqlSelection{
id?: boolean | number
universalIdentifier?: boolean | number
name?: boolean | number
description?: boolean | number
icon?: boolean | number
@@ -5707,7 +5708,6 @@ export interface CommandMenuItemGenqlSelection{
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
pageLayoutId?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -6744,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),pageLayoutId?: (Scalars['UUID'] | 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 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 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 CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
@@ -5036,6 +5036,9 @@ export default {
"id": [
1
],
"universalIdentifier": [
1
],
"name": [
1
],
@@ -5334,9 +5337,6 @@ export default {
"availabilityObjectMetadataId": [
3
],
"pageLayoutId": [
3
],
"applicationId": [
3
],
@@ -10669,9 +10669,6 @@ export default {
"payload": [
15
],
"pageLayoutId": [
3
],
"__typename": [
1
]
@@ -10707,9 +10704,6 @@ 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
@@ -172,14 +172,6 @@ const SettingsApplications = lazy(() =>
),
);
const SettingsApplicationDetails = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationDetails').then(
(module) => ({
default: module.SettingsApplicationDetails,
}),
),
);
const SettingsAdminApplicationRegistrationDetail = lazy(() =>
import(
'~/pages/settings/admin-panel/SettingsAdminApplicationRegistrationDetail'
@@ -188,12 +180,12 @@ const SettingsAdminApplicationRegistrationDetail = lazy(() =>
})),
);
const SettingsAvailableApplicationDetails = lazy(() =>
import(
'~/pages/settings/applications/SettingsAvailableApplicationDetails'
).then((module) => ({
default: module.SettingsAvailableApplicationDetails,
})),
const SettingsApplicationPage = lazy(() =>
import('~/pages/settings/applications/SettingsApplicationPage').then(
(module) => ({
default: module.SettingsApplicationPage,
}),
),
);
const SettingsApplicationRegistrationDetails = lazy(() =>
@@ -738,11 +730,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
/>
<Route
path={SettingsPath.ApplicationDetail}
element={<SettingsApplicationDetails />}
/>
<Route
path={SettingsPath.AvailableApplicationDetail}
element={<SettingsAvailableApplicationDetails />}
element={<SettingsApplicationPage />}
/>
<Route
path={SettingsPath.ApplicationRegistrationDetail}
@@ -32,7 +32,6 @@ 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 {
@@ -45,7 +44,6 @@ 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';
@@ -148,15 +146,10 @@ export const PageChangeEffect = () => {
if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname);
executeTasksOnAnyLocationChange();
const newPageLayoutId = getPageLayoutIdForLocation({
location,
store,
});
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
} else {
return;
}
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
}, [location, previousLocation, executeTasksOnAnyLocationChange]);
useEffect(() => {
initializeQueryParamState();
@@ -1,59 +0,0 @@
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;
};
@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
import { APPLICATION_REGISTRATION_FRAGMENT } from '@/settings/application-registrations/graphql/fragments/applicationRegistrationFragment';
export const FIND_APPLICATION_REGISTRATION_BY_UNIVERSAL_IDENTIFIER = gql`
query findApplicationRegistrationByUniversalIdentifier(
$universalIdentifier: String!
) {
findApplicationRegistrationByUniversalIdentifier(
universalIdentifier: $universalIdentifier
) {
...ApplicationRegistrationFragment
}
${APPLICATION_REGISTRATION_FRAGMENT}
}
`;
@@ -5,9 +5,7 @@ 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';
@@ -24,7 +22,6 @@ 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>(() => {
@@ -86,7 +83,6 @@ export const StandalonePageCommandMenu = () => {
item.availabilityType !==
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -96,7 +92,7 @@ export const StandalonePageCommandMenu = () => {
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
}, [commandMenuItems, commandMenuContextApi]);
return (
<CommandMenuContext.Provider
@@ -4,9 +4,7 @@ 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';
@@ -26,7 +24,6 @@ export const CommandMenuContextProviderContent = ({
commandMenuContextApi,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
@@ -37,7 +34,6 @@ export const CommandMenuContextProviderContent = ({
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -47,7 +43,7 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
}, [commandMenuItems, commandMenuContextApi]);
return (
<CommandMenuContext.Provider
@@ -3,7 +3,10 @@ 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(
@@ -24,18 +27,34 @@ 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}>
<FrontComponentRenderer
frontComponentId={context.frontComponentId}
commandMenuItemId={commandMenuItemId}
recordId={recordId}
/>
<LayoutRenderingProvider
value={{
targetRecordIdentifier:
isDefined(objectNameSingular) && isDefined(recordId)
? {
id: recordId,
targetObjectNameSingular: objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInSidePanel: false,
}}
>
<FrontComponentRenderer
frontComponentId={context.frontComponentId}
commandMenuItemId={commandMenuItemId}
/>
</LayoutRenderingProvider>
</Suspense>
);
};
@@ -9,6 +9,8 @@ import {
} from '~/generated-metadata/graphql';
const mockFindOneWorkflowVersion = jest.fn();
const mockEnqueueWarningSnackBar = jest.fn();
const mockBuildTriggerWorkflowVersionPayloads = jest.fn();
jest.mock('@/object-record/hooks/useLazyFindOneRecord', () => ({
useLazyFindOneRecord: () => ({
@@ -16,6 +18,20 @@ 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 }) => (
@@ -42,7 +58,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
jest.clearAllMocks();
});
it('should return enriched context with workflow metadata', async () => {
it('should return enriched context API with workflow info and payloads', async () => {
const store = createStore();
const wrapper = getWrapper(store);
@@ -59,6 +75,9 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
},
);
const expectedPayloads = [{ recordId: 'rec-1' }];
mockBuildTriggerWorkflowVersionPayloads.mockReturnValue(expectedPayloads);
const { result } = renderHook(
() =>
useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(),
@@ -75,8 +94,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
{
headlessEngineCommandContextApi,
workflowVersionId: 'wf-version-1',
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
},
);
});
@@ -85,9 +103,7 @@ describe('useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformatio
...headlessEngineCommandContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
availabilityObjectMetadataId: 'obj-1',
payloads: expectedPayloads,
});
});
@@ -118,4 +134,99 @@ 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',
},
}),
);
});
});
@@ -84,8 +84,7 @@ describe('useMountCommand', () => {
...baseContextApi,
workflowId: 'workflow-1',
workflowVersionId: 'wf-version-1',
trigger: { type: 'MANUAL' },
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
payloads: [{ recordId: 'rec-1' }],
};
mockEnrichFn.mockResolvedValue(enrichedState);
@@ -4,18 +4,27 @@ 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 } from 'twenty-shared/utils';
import { type CommandMenuItemAvailabilityType } from '~/generated-metadata/graphql';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
CommandMenuItemAvailabilityType as CommandMenuItemAvailabilityTypeEnum,
type CommandMenuItemAvailabilityType,
} from '~/generated-metadata/graphql';
type WorkflowVersionRecord = Pick<
WorkflowVersion,
'id' | 'workflowId' | 'trigger' | '__typename'
>;
type EnrichParams = {
type BuildTriggerWorkflowVersionCommandStateParams = {
headlessEngineCommandContextApi: HeadlessEngineCommandContextApi;
workflowVersionId: string;
availabilityType: CommandMenuItemAvailabilityType;
@@ -24,6 +33,9 @@ type EnrichParams = {
export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation =
() => {
const store = useStore();
const { enqueueWarningSnackBar } = useSnackBar();
const { findOneRecord: findOneWorkflowVersion } =
useLazyFindOneRecord<WorkflowVersionRecord>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
@@ -53,23 +65,76 @@ export const useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInforma
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
}: EnrichParams): Promise<HeadlessCommandContextApi | undefined> => {
}: BuildTriggerWorkflowVersionCommandStateParams): 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,
trigger: workflowVersion.trigger,
availabilityType,
availabilityObjectMetadataId,
payloads,
};
},
[fetchWorkflowVersion],
[store, fetchWorkflowVersion, enqueueWarningSnackBar],
);
return {
@@ -1,63 +1,23 @@
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 () => {
let selectedRecords: ObjectRecord[];
if (mountedCommandState.targetedRecordsRule.mode === 'selection') {
selectedRecords = mountedCommandState.selectedRecords;
} else {
selectedRecords = await fetchAllRecords();
if (
!isHeadlessTriggerWorkflowVersionCommandContextApi(mountedCommandState)
) {
return;
}
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const payloads = buildTriggerWorkflowVersionPayloads({
trigger: mountedCommandState.trigger,
availabilityType: mountedCommandState.availabilityType,
availabilityObjectMetadataId:
mountedCommandState.availabilityObjectMetadataId,
objectMetadataItems,
selectedRecords,
});
if (!isNonEmptyArray(payloads)) {
if (!isNonEmptyArray(mountedCommandState.payloads)) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
@@ -66,14 +26,14 @@ export const TriggerWorkflowVersionEngineCommand = () => {
return;
}
for (const payload of payloads) {
for (const payload of mountedCommandState.payloads) {
await runWorkflowVersion({
workflowId: mountedCommandState.workflowId,
workflowVersionId: mountedCommandState.workflowVersionId,
payload,
});
}
}, [mountedCommandState, fetchAllRecords, runWorkflowVersion, store]);
}, [runWorkflowVersion, mountedCommandState]);
return <HeadlessEngineCommandWrapperEffect execute={execute} />;
};
@@ -1,13 +1,11 @@
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';
@@ -33,9 +31,7 @@ export type HeadlessTriggerWorkflowVersionCommandContextApi =
HeadlessEngineCommandContextApi & {
workflowId: string;
workflowVersionId: string;
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
payloads: Record<string, any>[];
};
export type HeadlessCommandContextApi =
@@ -1,9 +1,6 @@
import { type HeadlessCommandContextApi } from '@/command-menu-item/engine-command/types/HeadlessCommandContextApi';
import { isHeadlessTriggerWorkflowVersionCommandContextApi } from '@/command-menu-item/engine-command/utils/isHeadlessTriggerWorkflowVersionCommandContextApi';
import {
CommandMenuItemAvailabilityType,
EngineComponentKey,
} from '~/generated-metadata/graphql';
import { EngineComponentKey } from '~/generated-metadata/graphql';
const baseContextApi: HeadlessCommandContextApi = {
engineComponentKey: EngineComponentKey.CREATE_NEW_RECORD,
@@ -23,8 +20,7 @@ describe('isHeadlessTriggerWorkflowVersionCommandContextApi', () => {
...baseContextApi,
workflowId: 'wf-1',
workflowVersionId: 'wfv-1',
trigger: null,
availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION,
payloads: [],
};
expect(
@@ -1,7 +1,10 @@
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 { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type WorkflowTrigger } from '@/workflow/types/Workflow';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemAvailabilityType,
@@ -9,31 +12,44 @@ import {
} from '~/generated-metadata/graphql';
export const buildTriggerWorkflowVersionPayloads = ({
store,
trigger,
availabilityType,
availabilityObjectMetadataId,
objectMetadataItems,
selectedRecords,
selectedRecordIds,
}: {
store: Store;
trigger: WorkflowTrigger | null;
availabilityType: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
objectMetadataItems: EnrichedObjectMetadataItem[];
selectedRecords: ObjectRecord[];
selectedRecordIds: string[];
}): Record<string, any>[] => {
const payloads: Record<string, any>[] = [];
switch (availabilityType) {
case CommandMenuItemAvailabilityTypeEnum.RECORD_SELECTION: {
if (selectedRecords.length === 0) {
if (selectedRecordIds.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,
@@ -43,7 +59,15 @@ export const buildTriggerWorkflowVersionPayloads = ({
return payloads;
}
for (const selectedRecord of selectedRecords) {
for (const selectedRecordId of limitedSelectedRecordIds) {
const selectedRecord = store.get(
recordStoreFamilyState.atomFamily(selectedRecordId),
);
if (!isDefined(selectedRecord)) {
continue;
}
payloads.push(selectedRecord);
}
@@ -28,6 +28,5 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
conditionalAvailabilityExpression
availabilityType
availabilityObjectMetadataId
pageLayoutId
}
`;
@@ -1,6 +0,0 @@
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;
@@ -18,13 +18,11 @@ 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();
@@ -35,11 +33,7 @@ export const FrontComponentRenderer = ({
);
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext({
frontComponentId,
commandMenuItemId,
recordId,
});
useFrontComponentExecutionContext({ frontComponentId, commandMenuItemId });
const handleError = useCallback(
(error?: Error) => {
@@ -17,6 +17,9 @@ 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,
@@ -83,6 +86,12 @@ 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';
@@ -90,6 +99,7 @@ describe('useFrontComponentExecutionContext', () => {
beforeEach(() => {
jest.clearAllMocks();
mockCurrentUser = { id: 'user-123' };
mockTargetRecordIdentifier = { id: 'record-456' };
});
describe('executionContext', () => {
@@ -97,7 +107,6 @@ describe('useFrontComponentExecutionContext', () => {
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
recordId: 'record-456',
}),
);
@@ -120,7 +129,9 @@ describe('useFrontComponentExecutionContext', () => {
expect(result.current.executionContext.userId).toBeNull();
});
it('should return null recordId when no recordId provided', () => {
it('should return null recordId when no target record', () => {
mockTargetRecordIdentifier = undefined;
const { result } = renderHook(() =>
useFrontComponentExecutionContext({
frontComponentId: FRONT_COMPONENT_ID,
@@ -14,6 +14,7 @@ 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';
@@ -23,11 +24,9 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = ({
frontComponentId,
commandMenuItemId,
recordId,
}: {
frontComponentId: string;
commandMenuItemId?: string;
recordId?: string;
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
@@ -124,10 +123,12 @@ export const useFrontComponentExecutionContext = ({
}
};
const { targetRecordIdentifier } = useLayoutRenderingContext();
const executionContext: FrontComponentExecutionContext = {
frontComponentId,
userId: currentUser?.id ?? null,
recordId: recordId ?? null,
recordId: targetRecordIdentifier?.id ?? null,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
@@ -3,6 +3,7 @@ import gql from 'graphql-tag';
export const MARKETPLACE_APP_FRAGMENT = gql`
fragment MarketplaceAppFields on MarketplaceApp {
id
universalIdentifier
name
description
icon
@@ -3,20 +3,14 @@ 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 (
@@ -31,28 +25,15 @@ export const ObjectOptionsDropdownFieldsContent = () => {
>
{t`Fields`}
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInput}
placeholder={t`Search fields`}
onChange={(event) => setSearchInput(event.target.value)}
/>
<ViewFieldsVisibleDropdownSection />
<DropdownMenuSeparator />
{isNonEmptyString(searchInput) ? (
<ViewFieldsSearchDropdownSection searchInput={searchInput} />
) : (
<>
<ViewFieldsVisibleDropdownSection />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}
text={t`Hidden Fields`}
/>
</DropdownMenuItemsContainer>
</>
)}
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
onClick={() => onContentChange('hiddenFields')}
LeftIcon={IconEyeOff}
text={t`Hidden Fields`}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -95,9 +95,7 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
if (value) {
store.set(currentPageLayoutIdState.atom, pageLayoutId);
}
store.set(currentPageLayoutIdState.atom, value ? pageLayoutId : null);
},
[
isDashboardInEditModeState,
@@ -7,7 +7,6 @@ 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%;
@@ -30,7 +29,6 @@ export const FrontComponentWidgetRenderer = ({
widget,
}: FrontComponentWidgetRendererProps) => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { targetRecordIdentifier } = useLayoutRenderingContext();
const configuration = widget.configuration;
@@ -46,10 +44,7 @@ export const FrontComponentWidgetRenderer = ({
return (
<StyledContainer isInEditMode={isPageLayoutInEditMode}>
<Suspense fallback={null}>
<FrontComponentRenderer
frontComponentId={frontComponentId}
recordId={targetRecordIdentifier?.id}
/>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</Suspense>
</StyledContainer>
);
@@ -102,7 +102,10 @@ export const SettingsAdminApps = () => {
key={registration.id}
to={getSettingsPath(
SettingsPath.AdminPanelApplicationRegistrationDetail,
{ applicationRegistrationId: registration.id },
{
applicationUniversalIdentifier:
registration.universalIdentifier,
},
)}
gridAutoColumns={TABLE_GRID}
mobileGridAutoColumns={TABLE_GRID_MOBILE}
@@ -1,65 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
import {
IconDeviceFloppy,
IconPencil,
IconRefreshAlert,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import {
ConfigSource,
type ConfigVariable,
} from '~/generated-metadata/graphql';
type ConfigVariableActionButtonsProps = {
variable: ConfigVariable;
isValueValid: boolean;
isSubmitting: boolean;
onSave: () => void;
onReset: () => void;
};
export const ConfigVariableActionButtons = ({
variable,
isValueValid,
isSubmitting,
onSave,
onReset,
}: ConfigVariableActionButtonsProps) => {
const { t } = useLingui();
const isConfigVariablesInDbEnabled = useAtomStateValue(
isConfigVariablesInDbEnabledState,
);
const isFromDatabase = variable.source === ConfigSource.DATABASE;
return (
<>
{isConfigVariablesInDbEnabled &&
variable.source === ConfigSource.DATABASE && (
<Button
title={t`Reset to Default`}
variant="secondary"
size="small"
accent="danger"
disabled={isSubmitting}
onClick={onReset}
Icon={IconRefreshAlert}
/>
)}
{isConfigVariablesInDbEnabled && !variable.isEnvOnly && (
<Button
title={isFromDatabase ? t`Save` : t`Edit`}
variant="primary"
size="small"
accent="blue"
disabled={isSubmitting || !isValueValid}
onClick={onSave}
type="submit"
Icon={isFromDatabase ? IconDeviceFloppy : IconPencil}
/>
)}
</>
);
};
@@ -1,82 +0,0 @@
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconChevronRight } from 'twenty-ui/display';
import { useContext } from 'react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type ConfigVariable } from '~/generated-metadata/graphql';
type SettingsAdminConfigVariablesRowProps = {
variable: ConfigVariable;
};
const StyledTableRowContainer = styled.div`
> * {
&:hover {
background-color: ${themeCssVariables.background.transparent.light};
}
}
`;
const StyledEllipsisLabel = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const SettingsAdminConfigVariablesRow = ({
variable,
}: SettingsAdminConfigVariablesRowProps) => {
const { theme } = useContext(ThemeContext);
const displayValue =
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: typeof variable.value === 'object' && variable.value !== null
? JSON.stringify(variable.value)
: variable.value;
return (
<StyledTableRowContainer>
<TableRow
gridAutoColumns="5fr 3fr 1fr"
to={getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
})}
>
<TableCell
color={theme.font.color.primary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<StyledEllipsisLabel>{variable.name}</StyledEllipsisLabel>
</TableCell>
<TableCell
align="right"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<StyledEllipsisLabel>{displayValue}</StyledEllipsisLabel>
</TableCell>
<TableCell align="right">
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
</StyledTableRowContainer>
);
};
@@ -1,16 +1,7 @@
import { t } from '@lingui/core/macro';
import { SettingsAdminConfigVariablesRow } from '@/settings/admin-panel/config-variables/components/SettingsAdminConfigVariablesRow';
import { Table } from '@/ui/layout/table/components/Table';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ConfigVariable } from '~/generated-metadata/graphql';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
import { ConfigVariableTable } from '@/settings/config-variables/components/ConfigVariableTable';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
type SettingsAdminConfigVariablesTableProps = {
variables: ConfigVariable[];
@@ -19,23 +10,25 @@ type SettingsAdminConfigVariablesTableProps = {
export const SettingsAdminConfigVariablesTable = ({
variables,
}: SettingsAdminConfigVariablesTableProps) => {
return (
<Table>
<TableRow gridAutoColumns="5fr 3fr 1fr">
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Value`}</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{variables.map((variable) => (
<SettingsAdminConfigVariablesRow
key={variable.name}
variable={variable}
/>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
);
const configVariables = variables.map((variable) => ({
name: variable.name,
description: variable.description,
value:
variable.value === ''
? 'null'
: variable.isSensitive
? '••••••'
: typeof variable.value === 'boolean'
? variable.value
? 'true'
: 'false'
: typeof variable.value === 'object' && variable.value !== null
? JSON.stringify(variable.value)
: variable.value,
to: getSettingsPath(SettingsPath.AdminPanelConfigVariableDetails, {
variableName: variable.name,
}),
}));
return <ConfigVariableTable configVariables={configVariables} />;
};
@@ -1,8 +1,5 @@
import { useLingui } from '@lingui/react/macro';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { type ConfigVariableValue } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
@@ -13,8 +10,6 @@ import {
} from '~/generated-metadata/graphql';
export const useConfigVariableActions = (variableName: string) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const [updateDatabaseConfigVariable] = useMutation(
@@ -31,65 +26,20 @@ export const useConfigVariableActions = (variableName: string) => {
value: ConfigVariableValue,
isFromDatabase: boolean,
) => {
try {
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
}
await refetchClientConfig();
enqueueSuccessSnackBar({
message: t`Variable updated successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to update variable`,
});
}
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
if (
value === null ||
(typeof value === 'string' && value === '') ||
(Array.isArray(value) && value.length === 0)
) {
await handleDeleteVariable();
return;
}
try {
await deleteDatabaseConfigVariable({
if (isFromDatabase) {
await updateDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
@@ -98,17 +48,42 @@ export const useConfigVariableActions = (variableName: string) => {
},
],
});
await refetchClientConfig();
enqueueSuccessSnackBar({
message: t`Variable deleted successfully.`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to remove override`,
} else {
await createDatabaseConfigVariable({
variables: {
key: variableName,
value,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
}
await refetchClientConfig();
};
const handleDeleteVariable = async (e?: React.MouseEvent<HTMLElement>) => {
if (isDefined(e)) {
e.preventDefault();
}
await deleteDatabaseConfigVariable({
variables: {
key: variableName,
},
refetchQueries: [
{
query: GET_DATABASE_CONFIG_VARIABLE,
variables: { key: variableName },
},
],
});
await refetchClientConfig();
};
return {
@@ -1,64 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { type ConfigVariableValue } from 'twenty-shared/types';
import { z } from 'zod';
import { type ConfigVariable } from '~/generated-metadata/graphql';
type FormValues = {
value: ConfigVariableValue;
};
const hasMeaningfulValue = (value: ConfigVariableValue): boolean => {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') {
return value.trim() !== '';
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
};
export const useConfigVariableForm = (variable?: ConfigVariable) => {
const validationSchema = z.object({
value: z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
z.null(),
]),
});
const {
control,
handleSubmit,
reset,
formState: { isSubmitting, isDirty },
watch,
} = useForm<FormValues>({
resolver: zodResolver(validationSchema),
values: { value: variable?.value ?? null },
});
const currentValue = watch('value');
const isValueValid =
variable !== undefined &&
!variable.isEnvOnly &&
isDirty &&
hasMeaningfulValue(currentValue);
return {
control,
handleSubmit,
reset,
isSubmitting,
currentValue,
hasValueChanged: isDirty,
isValueValid,
};
};
@@ -0,0 +1,171 @@
import { styled } from '@linaria/react';
import { H3Title, IconCheck, IconPencil, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useLingui } from '@lingui/react/macro';
import { Section } from 'twenty-ui/layout';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { ConfigVariableHelpText } from '@/settings/admin-panel/config-variables/components/ConfigVariableHelpText';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isDefined } from 'twenty-shared/utils';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
const RESET_VARIABLE_MODAL_ID =
'reset-application-registration-config-variable-modal';
const StyledRow = styled.div`
align-items: flex-end;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledButtonContainer = styled.div`
display: flex;
& > :not(:first-of-type) > button {
border-left: none;
}
`;
type ConfigVariableEditProps = {
title: string;
description?: string;
input: React.ReactNode;
isEditing: boolean;
setIsEditing: Dispatch<SetStateAction<boolean>>;
isSaveDisabled?: boolean;
canOpenCancelModal?: boolean;
onSave?: () => Promise<void>;
onCancel: () => void;
onEdit?: () => void;
onConfirmReset?: () => Promise<void>;
editDisabled?: boolean;
helpContent?: React.ReactNode;
};
export const ConfigVariableEdit = ({
title,
description,
input,
isEditing,
setIsEditing,
canOpenCancelModal,
isSaveDisabled = false,
onSave,
onCancel,
onEdit,
onConfirmReset,
editDisabled = false,
helpContent,
}: ConfigVariableEditProps) => {
const { t } = useLingui();
const { openModal } = useModal();
const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSave = async () => {
try {
setIsSubmitting(true);
await onSave?.();
enqueueSuccessSnackBar({
message: t`Variable ${title} updated`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error updating variable`,
});
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
};
const handleConfirmReset = async () => {
try {
setIsSubmitting(true);
await onConfirmReset?.();
enqueueSuccessSnackBar({
message: t`Variable ${title} reset`,
});
} catch {
enqueueErrorSnackBar({
message: t`Error resetting variable`,
});
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
};
const handleCancel = () => {
if (canOpenCancelModal) {
openModal(RESET_VARIABLE_MODAL_ID);
return;
}
onCancel?.();
setIsEditing(false);
};
const handleEdit = () => {
onEdit?.();
setIsEditing(true);
};
return (
<SettingsPageContainer>
<Section>
<H3Title title={title} description={description} />
</Section>
<Section>
<StyledRow>
{input}
{!isEditing ? (
<Button
Icon={IconPencil}
variant="primary"
onClick={handleEdit}
type="button"
disabled={editDisabled}
/>
) : (
<StyledButtonContainer>
<Button
Icon={IconCheck}
variant="secondary"
position="left"
type={'button'}
onClick={handleSave}
disabled={isSaveDisabled || isSubmitting}
/>
<Button
Icon={IconX}
variant="secondary"
position="right"
onClick={handleCancel}
type="button"
disabled={isSubmitting}
/>
</StyledButtonContainer>
)}
<ConfirmationModal
modalInstanceId={RESET_VARIABLE_MODAL_ID}
title={t`Reset variable`}
subtitle={t`Are you sure you want to reset this variable?`}
onConfirmClick={handleConfirmReset}
confirmButtonText={t`Reset`}
confirmButtonAccent="danger"
/>
</StyledRow>
{helpContent}
</Section>
</SettingsPageContainer>
);
};
@@ -0,0 +1,95 @@
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableBody } from '@/ui/layout/table/components/TableBody';
import { Table } from '@/ui/layout/table/components/Table';
import { styled } from '@linaria/react';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { t } from '@lingui/core/macro';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import {
IconChevronRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { useContext } from 'react';
const StyledTableBodyContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
`;
const GRID_AUTO_COLUMNS = '5fr 3fr 3fr 1fr';
type ConfigVariable = {
name: string;
description?: string;
value?: string | React.ReactNode;
to: string;
};
type ConfigVariableTableProps = { configVariables: ConfigVariable[] };
export const ConfigVariableTable = ({
configVariables,
}: ConfigVariableTableProps) => {
const { theme } = useContext(ThemeContext);
return (
<Table>
<TableRow gridAutoColumns={GRID_AUTO_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Description`}</TableHeader>
<TableHeader align="right">{t`Value`}</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
<StyledTableBodyContainer>
<TableBody>
{configVariables.map((variable, index) => (
<TableRow
key={index}
gridAutoColumns={GRID_AUTO_COLUMNS}
to={variable.to}
>
<TableCell
color={theme.font.color.primary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<OverflowingTextWithTooltip text={variable.name} />
</TableCell>
<TableCell
color={theme.font.color.secondary}
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
<OverflowingTextWithTooltip text={variable.description} />
</TableCell>
<TableCell
color={theme.font.color.secondary}
align="right"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
clickable
>
{typeof variable.value === 'string' ? (
<OverflowingTextWithTooltip text={variable.value} />
) : (
variable.value
)}
</TableCell>
<TableCell align="right" color={theme.font.color.secondary}>
<IconChevronRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</StyledTableBodyContainer>
</Table>
);
};
@@ -1,36 +0,0 @@
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { SettingsPath } from 'twenty-shared/types';
import { LinkChip } from 'twenty-ui/components';
import { getSettingsPath } from 'twenty-shared/utils';
import { useParams } from 'react-router-dom';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
export const SettingsLogicFunctionTabEnvironmentVariablesSection = () => {
const { applicationId = '' } = useParams<{ applicationId: string }>();
return (
<Section>
<H2Title
title={t`Environment Variables`}
description={t`Accessible in your function via process.env.KEY`}
/>
<Trans>
Environment variables are defined at application level for all
functions. Please check{' '}
<LinkChip
label={t`application detail page`}
to={getSettingsPath(
SettingsPath.ApplicationDetail,
{
applicationId,
},
undefined,
'settings',
)}
/>
.
</Trans>
</Section>
);
};
@@ -1,5 +1,4 @@
import { SettingsLogicFunctionNewForm } from '@/settings/logic-functions/components/SettingsLogicFunctionNewForm';
import { SettingsLogicFunctionTabEnvironmentVariablesSection } from '@/settings/logic-functions/components/SettingsLogicFunctionTabEnvironmentVariablesSection';
import { type LogicFunctionFormValues } from '@/logic-functions/hooks/useLogicFunctionUpdateFormState';
export const SettingsLogicFunctionSettingsTab = ({
@@ -12,12 +11,6 @@ export const SettingsLogicFunctionSettingsTab = ({
) => (value: LogicFunctionFormValues[TKey]) => void;
}) => {
return (
<>
<SettingsLogicFunctionNewForm
formValues={formValues}
onChange={onChange}
/>
<SettingsLogicFunctionTabEnvironmentVariablesSection />
</>
<SettingsLogicFunctionNewForm formValues={formValues} onChange={onChange} />
);
};
@@ -2,8 +2,10 @@ 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(
@@ -25,11 +27,22 @@ export const SidePanelFrontComponentPage = () => {
}
return (
<Suspense fallback={null}>
<FrontComponentRenderer
frontComponentId={viewableFrontComponentId}
recordId={viewableFrontComponentRecordContext?.recordId}
/>
</Suspense>
<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>
);
};

Some files were not shown because too many files have changed in this diff Show More