[Command menu items] Create engine commands (#18681)

## Description

- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`

The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.
This commit is contained in:
Raphaël Bosi
2026-03-17 17:25:18 +01:00
committed by GitHub
parent bee474afdf
commit abdab2fb7e
83 changed files with 2594 additions and 132 deletions
@@ -0,0 +1,95 @@
import { useIsHeadlessEngineCommandEffectInitialized } from '@/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized';
import { type ReactNode, useEffect } from 'react';
import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from '@/command-menu-item/confirmation-modal/constants/CommandMenuItemConfirmationModalResultBrowserEventName';
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
import { type CommandMenuConfirmationModalResultBrowserEventDetail } from '@/command-menu-item/confirmation-modal/types/CommandMenuConfirmationModalResultBrowserEventDetail';
import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { type ButtonAccent } from 'twenty-ui/input';
export type HeadlessConfirmationModalEngineCommandEffectProps = {
title: string;
subtitle: ReactNode;
confirmButtonText: string;
confirmButtonAccent?: ButtonAccent;
execute: () => void | Promise<unknown>;
};
export const HeadlessConfirmationModalEngineCommandEffect = ({
title,
subtitle,
confirmButtonText,
confirmButtonAccent = 'danger',
execute,
}: HeadlessConfirmationModalEngineCommandEffectProps) => {
const { isInitializedRef, setIsInitialized } =
useIsHeadlessEngineCommandEffectInitialized();
const engineCommandId = useAvailableComponentInstanceIdOrThrow(
EngineCommandComponentInstanceContext,
);
const unmountEngineCommand = useUnmountEngineCommand();
const { openConfirmationModal } = useCommandMenuConfirmationModal();
useEffect(() => {
if (isInitializedRef.current) {
return;
}
setIsInitialized(true);
openConfirmationModal({
caller: { type: 'engineCommand', engineCommandId },
title,
subtitle,
confirmButtonText,
confirmButtonAccent,
});
}, [
isInitializedRef,
setIsInitialized,
engineCommandId,
openConfirmationModal,
title,
subtitle,
confirmButtonText,
confirmButtonAccent,
]);
useEffect(() => {
const handleConfirmationResult = async (event: Event) => {
const customEvent =
event as CustomEvent<CommandMenuConfirmationModalResultBrowserEventDetail>;
const caller = customEvent.detail.caller;
if (
caller.type !== 'engineCommand' ||
caller.engineCommandId !== engineCommandId
) {
return;
}
if (customEvent.detail.confirmationResult === 'confirm') {
await execute();
}
unmountEngineCommand(engineCommandId);
};
window.addEventListener(
COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleConfirmationResult,
);
return () => {
window.removeEventListener(
COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleConfirmationResult,
);
};
}, [engineCommandId, execute, unmountEngineCommand]);
return null;
};
@@ -0,0 +1,41 @@
import { CommandMenuItemErrorBoundary } from '@/command-menu-item/display/components/CommandMenuItemErrorBoundary';
import { ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP } from '@/command-menu-item/engine-command/constants/EngineComponentKeyHeadlessComponentMap';
import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const HeadlessEngineCommandMountRoot = () => {
const mountedEngineCommands = useAtomStateValue(mountedEngineCommandsState);
const unmountEngineCommand = useUnmountEngineCommand();
return (
<>
{[...mountedEngineCommands.entries()].map(
([engineCommandId, mountContext]) => (
<CommandMenuItemErrorBoundary
key={engineCommandId}
engineCommandId={engineCommandId}
shouldReportToSentry
onError={() => unmountEngineCommand(engineCommandId)}
>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: mountContext.contextStoreInstanceId }}
>
<EngineCommandComponentInstanceContext.Provider
value={{ instanceId: engineCommandId }}
>
{
ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP[
mountContext.engineComponentKey
]
}
</EngineCommandComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</CommandMenuItemErrorBoundary>
),
)}
</>
);
};
@@ -0,0 +1,50 @@
import { useIsHeadlessEngineCommandEffectInitialized } from '@/command-menu-item/engine-command/hooks/useIsHeadlessEngineCommandEffectInitialized';
import { useUnmountEngineCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useEffect } from 'react';
export type HeadlessEngineCommandWrapperEffectProps = {
execute: () => void | Promise<unknown>;
};
export const HeadlessEngineCommandWrapperEffect = ({
execute,
}: HeadlessEngineCommandWrapperEffectProps) => {
const { isInitializedRef, setIsInitialized } =
useIsHeadlessEngineCommandEffectInitialized();
const engineCommandId = useAvailableComponentInstanceIdOrThrow(
EngineCommandComponentInstanceContext,
);
const unmountEngineCommand = useUnmountEngineCommand();
const { enqueueErrorSnackBar } = useSnackBar();
useEffect(() => {
if (isInitializedRef.current) {
return;
}
setIsInitialized(true);
const run = async () => {
await execute();
unmountEngineCommand(engineCommandId);
};
run();
}, [
execute,
isInitializedRef,
setIsInitialized,
engineCommandId,
unmountEngineCommand,
enqueueErrorSnackBar,
]);
return null;
};
@@ -0,0 +1,24 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { type PathParam, useNavigate } from 'react-router-dom';
import { type AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
export const HeadlessNavigateEngineCommand = <T extends AppPath>({
to,
params,
queryParams,
}: {
to: T;
params?: { [key in PathParam<T>]: string | null };
queryParams?: Record<string, any>;
}) => {
const navigate = useNavigate();
const path = getAppPath(to, params, queryParams);
// eslint-disable-next-line twenty/no-navigate-prefer-link
const onExecute = () => {
navigate(path);
};
return <HeadlessEngineCommandWrapperEffect execute={onExecute} />;
};
@@ -0,0 +1,37 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { type MessageDescriptor } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { type SidePanelPages } from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
export const HeadlessOpenSidePanelPageEngineCommand = ({
page,
pageTitle,
pageIcon,
shouldResetSearchState = false,
}: {
page: SidePanelPages;
pageTitle: MessageDescriptor;
pageIcon: IconComponent;
shouldResetSearchState?: boolean;
}) => {
const { navigateSidePanel } = useNavigateSidePanel();
const setSidePanelSearch = useSetAtomState(sidePanelSearchState);
const onExecute = () => {
navigateSidePanel({
page,
pageTitle: t(pageTitle),
pageIcon,
});
if (shouldResetSearchState) {
setSidePanelSearch('');
}
};
return <HeadlessEngineCommandWrapperEffect execute={onExecute} />;
};
@@ -0,0 +1,254 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { HeadlessOpenSidePanelPageEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessOpenSidePanelPageEngineCommand';
import { DeleteMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/DeleteMultipleRecordsCommand';
import { DestroyMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/DestroyMultipleRecordsCommand';
import { ExportMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/ExportMultipleRecordsCommand';
import { MergeMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/MergeMultipleRecordsCommand';
import { RestoreMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/RestoreMultipleRecordsCommand';
import { UpdateMultipleRecordsCommand } from '@/command-menu-item/engine-command/record/multiple-records/components/UpdateMultipleRecordsCommand';
import { CreateNewIndexRecordNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewIndexRecordNoSelectionRecordCommand';
import { CreateNewViewNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/CreateNewViewNoSelectionRecordCommand';
import { HideDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/HideDeletedRecordsNoSelectionRecordCommand';
import { ImportRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/ImportRecordsNoSelectionRecordCommand';
import { SeeDeletedRecordsNoSelectionRecordCommand } from '@/command-menu-item/engine-command/record/no-selection/components/SeeDeletedRecordsNoSelectionRecordCommand';
import { AddToFavoritesSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/AddToFavoritesSingleRecordCommand';
import { DeleteSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/DeleteSingleRecordCommand';
import { DestroySingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/DestroySingleRecordCommand';
import { ExportNoteSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/ExportNoteSingleRecordCommand';
import { ExportSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/ExportSingleRecordCommand';
import { NavigateToNextRecordSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/NavigateToNextRecordSingleRecordCommand';
import { NavigateToPreviousRecordSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/NavigateToPreviousRecordSingleRecordCommand';
import { RemoveFromFavoritesSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/RemoveFromFavoritesSingleRecordCommand';
import { RestoreSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/components/RestoreSingleRecordCommand';
import { CancelDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/CancelDashboardSingleRecordCommand';
import { DuplicateDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/DuplicateDashboardSingleRecordCommand';
import { EditDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/EditDashboardSingleRecordCommand';
import { SaveDashboardSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/dashboard/components/SaveDashboardSingleRecordCommand';
import { EditRecordPageLayoutSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/record-page-layout/components/EditRecordPageLayoutSingleRecordCommand';
import { SeeVersionWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeVersionWorkflowRunSingleRecordCommand';
import { SeeWorkflowWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/SeeWorkflowWorkflowRunSingleRecordCommand';
import { StopWorkflowRunSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-runs/components/StopWorkflowRunSingleRecordCommand';
import { SeeRunsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeRunsWorkflowVersionSingleRecordCommand';
import { SeeVersionsWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeVersionsWorkflowVersionSingleRecordCommand';
import { SeeWorkflowWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/SeeWorkflowWorkflowVersionSingleRecordCommand';
import { UseAsDraftWorkflowVersionSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow-versions/components/UseAsDraftWorkflowVersionSingleRecordCommand';
import { ActivateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/ActivateWorkflowSingleRecordCommand';
import { AddNodeWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/AddNodeWorkflowSingleRecordCommand';
import { DeactivateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DeactivateWorkflowSingleRecordCommand';
import { DiscardDraftWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DiscardDraftWorkflowSingleRecordCommand';
import { DuplicateWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/DuplicateWorkflowSingleRecordCommand';
import { SeeActiveVersionWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeActiveVersionWorkflowSingleRecordCommand';
import { SeeRunsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeRunsWorkflowSingleRecordCommand';
import { SeeVersionsWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/SeeVersionsWorkflowSingleRecordCommand';
import { TestWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TestWorkflowSingleRecordCommand';
import { TidyUpWorkflowSingleRecordCommand } from '@/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { msg } from '@lingui/core/macro';
import { AppPath, SettingsPath, SidePanelPages } from 'twenty-shared/types';
import { IconHistory, IconSearch, IconSparkles } from 'twenty-ui/display';
import { EngineComponentKey } from '~/generated-metadata/graphql';
export const ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP: Record<
EngineComponentKey,
React.ReactNode
> = {
[EngineComponentKey.CREATE_NEW_RECORD]: (
<CreateNewIndexRecordNoSelectionRecordCommand />
),
[EngineComponentKey.DELETE_SINGLE_RECORD]: <DeleteSingleRecordCommand />,
[EngineComponentKey.DELETE_MULTIPLE_RECORDS]: (
<DeleteMultipleRecordsCommand />
),
[EngineComponentKey.RESTORE_SINGLE_RECORD]: <RestoreSingleRecordCommand />,
[EngineComponentKey.RESTORE_MULTIPLE_RECORDS]: (
<RestoreMultipleRecordsCommand />
),
[EngineComponentKey.DESTROY_SINGLE_RECORD]: <DestroySingleRecordCommand />,
[EngineComponentKey.DESTROY_MULTIPLE_RECORDS]: (
<DestroyMultipleRecordsCommand />
),
[EngineComponentKey.ADD_TO_FAVORITES]: <AddToFavoritesSingleRecordCommand />,
[EngineComponentKey.REMOVE_FROM_FAVORITES]: (
<RemoveFromFavoritesSingleRecordCommand />
),
[EngineComponentKey.MERGE_MULTIPLE_RECORDS]: <MergeMultipleRecordsCommand />,
[EngineComponentKey.DUPLICATE_DASHBOARD]: (
<DuplicateDashboardSingleRecordCommand />
),
[EngineComponentKey.DUPLICATE_WORKFLOW]: (
<DuplicateWorkflowSingleRecordCommand />
),
[EngineComponentKey.ACTIVATE_WORKFLOW]: (
<ActivateWorkflowSingleRecordCommand />
),
[EngineComponentKey.DEACTIVATE_WORKFLOW]: (
<DeactivateWorkflowSingleRecordCommand />
),
[EngineComponentKey.DISCARD_DRAFT_WORKFLOW]: (
<DiscardDraftWorkflowSingleRecordCommand />
),
[EngineComponentKey.TEST_WORKFLOW]: <TestWorkflowSingleRecordCommand />,
[EngineComponentKey.STOP_WORKFLOW_RUN]: (
<StopWorkflowRunSingleRecordCommand />
),
[EngineComponentKey.USE_AS_DRAFT_WORKFLOW_VERSION]: (
<UseAsDraftWorkflowVersionSingleRecordCommand />
),
[EngineComponentKey.SAVE_DASHBOARD_LAYOUT]: (
<SaveDashboardSingleRecordCommand />
),
[EngineComponentKey.TIDY_UP_WORKFLOW]: <TidyUpWorkflowSingleRecordCommand />,
[EngineComponentKey.NAVIGATE_TO_NEXT_RECORD]: (
<NavigateToNextRecordSingleRecordCommand />
),
[EngineComponentKey.NAVIGATE_TO_PREVIOUS_RECORD]: (
<NavigateToPreviousRecordSingleRecordCommand />
),
[EngineComponentKey.EXPORT_NOTE_TO_PDF]: <ExportNoteSingleRecordCommand />,
[EngineComponentKey.EXPORT_FROM_RECORD_INDEX]: (
<ExportMultipleRecordsCommand />
),
[EngineComponentKey.EXPORT_FROM_RECORD_SHOW]: <ExportSingleRecordCommand />,
[EngineComponentKey.EXPORT_MULTIPLE_RECORDS]: (
<ExportMultipleRecordsCommand />
),
[EngineComponentKey.UPDATE_MULTIPLE_RECORDS]: (
<UpdateMultipleRecordsCommand />
),
[EngineComponentKey.IMPORT_RECORDS]: (
<ImportRecordsNoSelectionRecordCommand />
),
[EngineComponentKey.EXPORT_VIEW]: <ExportMultipleRecordsCommand />,
[EngineComponentKey.SEE_DELETED_RECORDS]: (
<SeeDeletedRecordsNoSelectionRecordCommand />
),
[EngineComponentKey.CREATE_NEW_VIEW]: (
<CreateNewViewNoSelectionRecordCommand />
),
[EngineComponentKey.HIDE_DELETED_RECORDS]: (
<HideDeletedRecordsNoSelectionRecordCommand />
),
[EngineComponentKey.EDIT_RECORD_PAGE_LAYOUT]: (
<EditRecordPageLayoutSingleRecordCommand />
),
[EngineComponentKey.EDIT_DASHBOARD_LAYOUT]: (
<EditDashboardSingleRecordCommand />
),
[EngineComponentKey.CANCEL_DASHBOARD_LAYOUT]: (
<CancelDashboardSingleRecordCommand />
),
[EngineComponentKey.GO_TO_PEOPLE]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Person }}
/>
),
[EngineComponentKey.GO_TO_COMPANIES]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Company }}
/>
),
[EngineComponentKey.GO_TO_DASHBOARDS]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Dashboard }}
/>
),
[EngineComponentKey.GO_TO_OPPORTUNITIES]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{
objectNamePlural: CoreObjectNamePlural.Opportunity,
}}
/>
),
[EngineComponentKey.GO_TO_SETTINGS]: (
<HeadlessNavigateEngineCommand
to={AppPath.SettingsCatchAll}
params={{
'*': SettingsPath.ProfilePage,
}}
/>
),
[EngineComponentKey.GO_TO_TASKS]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Task }}
/>
),
[EngineComponentKey.GO_TO_NOTES]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Note }}
/>
),
[EngineComponentKey.GO_TO_WORKFLOWS]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.Workflow }}
/>
),
[EngineComponentKey.GO_TO_RUNS]: (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
/>
),
[EngineComponentKey.SEARCH_RECORDS]: (
<HeadlessOpenSidePanelPageEngineCommand
page={SidePanelPages.SearchRecords}
pageTitle={msg`Search`}
pageIcon={IconSearch}
shouldResetSearchState={true}
/>
),
[EngineComponentKey.SEARCH_RECORDS_FALLBACK]: (
<HeadlessOpenSidePanelPageEngineCommand
page={SidePanelPages.SearchRecords}
pageTitle={msg`Search`}
pageIcon={IconSearch}
/>
),
[EngineComponentKey.ASK_AI]: (
<HeadlessOpenSidePanelPageEngineCommand
page={SidePanelPages.AskAI}
pageTitle={msg`Ask AI`}
pageIcon={IconSparkles}
/>
),
[EngineComponentKey.VIEW_PREVIOUS_AI_CHATS]: (
<HeadlessOpenSidePanelPageEngineCommand
page={SidePanelPages.ViewPreviousAIChats}
pageTitle={msg`View Previous AI Chats`}
pageIcon={IconHistory}
/>
),
[EngineComponentKey.SEE_ACTIVE_VERSION_WORKFLOW]: (
<SeeActiveVersionWorkflowSingleRecordCommand />
),
[EngineComponentKey.SEE_RUNS_WORKFLOW]: (
<SeeRunsWorkflowSingleRecordCommand />
),
[EngineComponentKey.SEE_VERSIONS_WORKFLOW]: (
<SeeVersionsWorkflowSingleRecordCommand />
),
[EngineComponentKey.ADD_NODE_WORKFLOW]: (
<AddNodeWorkflowSingleRecordCommand />
),
[EngineComponentKey.SEE_VERSION_WORKFLOW_RUN]: (
<SeeVersionWorkflowRunSingleRecordCommand />
),
[EngineComponentKey.SEE_WORKFLOW_WORKFLOW_RUN]: (
<SeeWorkflowWorkflowRunSingleRecordCommand />
),
[EngineComponentKey.SEE_RUNS_WORKFLOW_VERSION]: (
<SeeRunsWorkflowVersionSingleRecordCommand />
),
[EngineComponentKey.SEE_WORKFLOW_WORKFLOW_VERSION]: (
<SeeWorkflowWorkflowVersionSingleRecordCommand />
),
[EngineComponentKey.SEE_VERSIONS_WORKFLOW_VERSION]: (
<SeeVersionsWorkflowVersionSingleRecordCommand />
),
};
@@ -0,0 +1,12 @@
import { useRef } from 'react';
export const useIsHeadlessEngineCommandEffectInitialized = () => {
// eslint-disable-next-line twenty/no-state-useref
const isInitializedRef = useRef(false);
const setIsInitialized = (value: boolean) => {
isInitializedRef.current = value;
};
return { isInitializedRef, setIsInitialized };
};
@@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreFilterGroupsComponentState } from '@/context-store/states/contextStoreFilterGroupsComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import { type EngineComponentKey } from '~/generated-metadata/graphql';
export const useMountEngineCommand = () => {
const store = useStore();
const mountEngineCommand = useCallback(
(
engineCommandId: string,
contextStoreInstanceId: string,
engineComponentKey: EngineComponentKey,
) => {
const objectMetadataItemId = store.get(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataItemId,
);
const currentViewId = store.get(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const targetedRecordsRule = store.get(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const selectedRecords = (
targetedRecordsRule.mode === 'selection'
? targetedRecordsRule.selectedRecordIds
: []
)
.map((id) => store.get(recordStoreFamilyState.atomFamily(id)))
.filter(isDefined);
const filters = store.get(
contextStoreFiltersComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const filterGroups = store.get(
contextStoreFilterGroupsComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const anyFieldFilterValue = store.get(
contextStoreAnyFieldFilterValueComponentState.atomFamily({
instanceId: contextStoreInstanceId,
}),
);
const currentWorkspaceMember = store.get(
currentWorkspaceMemberState.atom,
);
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const userTimezone =
currentWorkspaceMember?.timeZone !== 'system'
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const graphqlFilter = isDefined(objectMetadataItem)
? computeContextStoreFilters({
contextStoreTargetedRecordsRule: targetedRecordsRule,
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
},
contextStoreAnyFieldFilterValue: anyFieldFilterValue,
})
: null;
const recordIndexId =
objectMetadataItem && currentViewId
? getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
currentViewId,
)
: null;
store.set(mountedEngineCommandsState.atom, (previousMap) => {
const newMap = new Map(previousMap);
newMap.set(engineCommandId, {
engineComponentKey,
contextStoreInstanceId,
objectMetadataItem: objectMetadataItem ?? null,
currentViewId,
recordIndexId,
targetedRecordsRule,
selectedRecords,
graphqlFilter,
});
return newMap;
});
},
[store],
);
return mountEngineCommand;
};
@@ -0,0 +1,22 @@
import { EngineCommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/EngineCommandComponentInstanceContext';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
export const useMountedEngineCommandContext = () => {
const engineCommandId = useAvailableComponentInstanceIdOrThrow(
EngineCommandComponentInstanceContext,
);
const mountedEngineCommands = useAtomStateValue(mountedEngineCommandsState);
const context = mountedEngineCommands.get(engineCommandId);
if (!isDefined(context)) {
throw new Error(
'Engine command mount context not found. Make sure the command was mounted via the engine command mount flow.',
);
}
return context;
};
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { mountedEngineCommandsState } from '@/command-menu-item/engine-command/states/mountedEngineCommandsState';
import { useStore } from 'jotai';
export const useUnmountEngineCommand = () => {
const store = useStore();
const unmountEngineCommand = useCallback(
(engineCommandId: string) => {
store.set(mountedEngineCommandsState.atom, (previousMap) => {
const newMap = new Map(previousMap);
newMap.delete(engineCommandId);
return newMap;
});
},
[store],
);
return unmountEngineCommand;
};
@@ -0,0 +1,42 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useIncrementalDeleteManyRecords } from '@/object-record/hooks/useIncrementalDeleteManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { isDefined } from 'twenty-shared/utils';
export const DeleteMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem, graphqlFilter } =
useMountedEngineCommandContext();
if (
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem) ||
!isDefined(graphqlFilter)
) {
throw new Error(
'Record index ID, object metadata item, and graphql filter are required to delete multiple records',
);
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { incrementalDeleteManyRecords } = useIncrementalDeleteManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: graphqlFilter,
pageSize: DEFAULT_QUERY_PAGE_SIZE,
delayInMsBetweenMutations: 50,
});
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await incrementalDeleteManyRecords();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,61 @@
import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useIncrementalDestroyManyRecords } from '@/object-record/hooks/useIncrementalDestroyManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const DestroyMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem, graphqlFilter } =
useMountedEngineCommandContext();
if (
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem) ||
!isDefined(graphqlFilter)
) {
throw new Error(
'Record index ID, object metadata item, and graphql filter are required to destroy multiple records',
);
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const deletedAtFilter: RecordGqlOperationFilter = {
deletedAt: { is: 'NOT_NULL' },
};
const combinedFilter = {
...graphqlFilter,
...deletedAtFilter,
};
const { incrementalDestroyManyRecords } = useIncrementalDestroyManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: combinedFilter,
pageSize: DEFAULT_QUERY_PAGE_SIZE,
delayInMsBetweenMutations: 50,
});
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await incrementalDestroyManyRecords();
};
return (
<HeadlessConfirmationModalEngineCommandEffect
title={t`Permanently Destroy Records`}
subtitle={t`Are you sure you want to destroy these records? They won't be recoverable anymore.`}
confirmButtonText={t`Destroy Records`}
execute={handleExecute}
/>
);
};
@@ -0,0 +1,24 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordIndexExportRecords } from '@/object-record/record-index/export/hooks/useRecordIndexExportRecords';
import { isDefined } from 'twenty-shared/utils';
export const ExportMultipleRecordsCommand = () => {
const { objectMetadataItem, recordIndexId } =
useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) {
throw new Error(
'Object metadata item and record index ID are required to export multiple records',
);
}
const { download } = useRecordIndexExportRecords({
delayMs: 100,
objectMetadataItem,
recordIndexId,
filename: `${objectMetadataItem.nameSingular}.csv`,
});
return <HeadlessEngineCommandWrapperEffect execute={download} />;
};
@@ -0,0 +1,29 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useOpenMergeRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenMergeRecordsPageInSidePanel';
import { isDefined } from 'twenty-shared/utils';
export const MergeMultipleRecordsCommand = () => {
const { objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const selectedRecordIds = selectedRecords.map((record) => record.id);
if (!isDefined(objectMetadataItem) || selectedRecordIds.length === 0) {
throw new Error(
'Object metadata item and selected records are required to merge multiple records',
);
}
const { openMergeRecordsPageInSidePanel } =
useOpenMergeRecordsPageInSidePanel({
objectNameSingular: objectMetadataItem.nameSingular,
objectRecordIds: selectedRecordIds,
});
const handleExecute = () => {
openMergeRecordsPageInSidePanel();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,71 @@
import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const RestoreMultipleRecordsCommand = () => {
const { recordIndexId, objectMetadataItem, graphqlFilter } =
useMountedEngineCommandContext();
if (
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem) ||
!isDefined(graphqlFilter)
) {
throw new Error(
'Record index ID, object metadata item, and graphql filter are required to restore multiple records',
);
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const deletedAtFilter: RecordGqlOperationFilter = {
deletedAt: { is: 'NOT_NULL' },
};
const combinedFilter = {
...graphqlFilter,
...deletedAtFilter,
};
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({
objectNameSingular: objectMetadataItem.nameSingular,
filter: combinedFilter,
limit: DEFAULT_QUERY_PAGE_SIZE,
recordGqlFields: { id: true },
});
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
const recordsToRestore = await fetchAllRecordIds();
const recordIdsToRestore = recordsToRestore.map((record) => record.id);
resetTableRowSelection();
await restoreManyRecords({
idsToRestore: recordIdsToRestore,
});
};
return (
<HeadlessConfirmationModalEngineCommandEffect
title={t`Restore Records`}
subtitle={t`Are you sure you want to restore these records?`}
confirmButtonText={t`Restore Records`}
confirmButtonAccent="default"
execute={handleExecute}
/>
);
};
@@ -0,0 +1,18 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useOpenUpdateMultipleRecordsPageInSidePanel } from '@/side-panel/hooks/useOpenUpdateMultipleRecordsPageInSidePanel';
export const UpdateMultipleRecordsCommand = () => {
const { contextStoreInstanceId } = useMountedEngineCommandContext();
const { openUpdateMultipleRecordsPageInSidePanel } =
useOpenUpdateMultipleRecordsPageInSidePanel({
contextStoreInstanceId,
});
const handleExecute = () => {
openUpdateMultipleRecordsPageInSidePanel();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,26 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isDefined } from 'twenty-shared/utils';
export const CreateNewIndexRecordNoSelectionRecordCommand = () => {
const { objectMetadataItem, recordIndexId } =
useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) {
throw new Error(
'Object metadata item and record index ID are required to create new index record',
);
}
const { createNewIndexRecord } = useCreateNewIndexRecord({
objectMetadataItem,
instanceId: recordIndexId,
});
return (
<HeadlessEngineCommandWrapperEffect
execute={() => createNewIndexRecord({ position: 'first' })}
/>
);
};
@@ -0,0 +1,39 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { isDefined } from 'twenty-shared/utils';
export const CreateNewViewNoSelectionRecordCommand = () => {
const { currentViewId, recordIndexId } = useMountedEngineCommandContext();
const { openDropdown } = useOpenDropdown();
if (!isDefined(currentViewId) || !isDefined(recordIndexId)) {
throw new Error(
'Current view ID and record index ID are required to create new view',
);
}
const setViewPickerReferenceViewId = useSetAtomComponentState(
viewPickerReferenceViewIdComponentState,
recordIndexId,
);
const { setViewPickerMode } = useViewPickerMode(recordIndexId);
const handleExecute = () => {
if (currentViewId) {
setViewPickerReferenceViewId(currentViewId);
}
setViewPickerMode('create-empty');
openDropdown({
dropdownComponentInstanceIdFromProps: VIEW_PICKER_DROPDOWN_ID,
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,49 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useCheckIsSoftDeleteFilter } from '@/object-record/record-filter/hooks/useCheckIsSoftDeleteFilter';
import { useRemoveRecordFilter } from '@/object-record/record-filter/hooks/useRemoveRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
export const HideDeletedRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem, recordIndexId } =
useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) {
throw new Error(
'Object metadata item and record index ID are required to hide deleted records',
);
}
const { toggleSoftDeleteFilterState } = useHandleToggleTrashColumnFilter({
objectNameSingular: objectMetadataItem.nameSingular,
viewBarId: recordIndexId,
recordFiltersInstanceId: recordIndexId,
});
const { isRecordFilterAboutSoftDelete } = useCheckIsSoftDeleteFilter();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
recordIndexId,
);
const deletedFilter = currentRecordFilters.find(
isRecordFilterAboutSoftDelete,
);
const { removeRecordFilter } = useRemoveRecordFilter(recordIndexId);
const handleExecute = () => {
if (!isDefined(deletedFilter)) {
return;
}
removeRecordFilter({ recordFilterId: deletedFilter.id });
toggleSoftDeleteFilterState(false);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,23 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useOpenObjectRecordsSpreadsheetImportDialog } from '@/object-record/spreadsheet-import/hooks/useOpenObjectRecordsSpreadsheetImportDialog';
import { isDefined } from 'twenty-shared/utils';
export const ImportRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem } = useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem)) {
throw new Error('Object metadata item is required to import records');
}
const { openObjectRecordsSpreadsheetImportDialog } =
useOpenObjectRecordsSpreadsheetImportDialog(
objectMetadataItem.nameSingular,
);
return (
<HeadlessEngineCommandWrapperEffect
execute={openObjectRecordsSpreadsheetImportDialog}
/>
);
};
@@ -0,0 +1,31 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter';
import { isDefined } from 'twenty-shared/utils';
export const SeeDeletedRecordsNoSelectionRecordCommand = () => {
const { objectMetadataItem, recordIndexId } =
useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem) || !isDefined(recordIndexId)) {
throw new Error(
'Object metadata item and record index ID are required to see deleted records',
);
}
const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } =
useHandleToggleTrashColumnFilter({
objectNameSingular: objectMetadataItem.nameSingular,
viewBarId: recordIndexId,
recordFiltersInstanceId: recordIndexId,
});
return (
<HeadlessEngineCommandWrapperEffect
execute={() => {
handleToggleTrashColumnFilter();
toggleSoftDeleteFilterState(true);
}}
/>
);
};
@@ -0,0 +1,27 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem';
import { isDefined } from 'twenty-shared/utils';
export const AddToFavoritesSingleRecordCommand = () => {
const { objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(objectMetadataItem)) {
throw new Error('Object metadata item is required to add to favorites');
}
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const handleExecute = () => {
if (!isDefined(selectedRecord)) {
return;
}
createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,59 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/common/hooks/useRemoveNavigationMenuItemByTargetRecordId';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { isDefined } from 'twenty-shared/utils';
export const DeleteSingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (
!isDefined(recordId) ||
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem)
) {
throw new Error(
'Record ID, record index ID, and object metadata are required to delete single record',
);
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { deleteOneRecord } = useDeleteOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { removeNavigationMenuItemsByTargetRecordIds } =
useRemoveNavigationMenuItemByTargetRecordId();
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find((item) => item.targetRecordId === recordId);
if (isDefined(foundNavigationMenuItem)) {
removeNavigationMenuItemsByTargetRecordIds([recordId]);
}
await deleteOneRecord(recordId);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,56 @@
import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useDestroyOneRecord } from '@/object-record/hooks/useDestroyOneRecord';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DestroySingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (
!isDefined(recordId) ||
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem)
) {
throw new Error(
'Record ID, record index ID, and object metadata are required to destroy single record',
);
}
const navigateApp = useNavigateApp();
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { destroyOneRecord } = useDestroyOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
});
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await destroyOneRecord(recordId);
navigateApp(AppPath.RecordIndexPage, {
objectNamePlural: objectMetadataItem.namePlural,
});
};
return (
<HeadlessConfirmationModalEngineCommandEffect
title={t`Permanently Destroy Record`}
subtitle={t`Are you sure you want to destroy this record? It cannot be recovered anymore.`}
confirmButtonText={t`Permanently Destroy Record`}
execute={handleExecute}
/>
);
};
@@ -0,0 +1,44 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { isDefined } from 'twenty-shared/utils';
export const ExportNoteSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
const recordId = selectedRecord?.id;
if (!isDefined(recordId) || !isDefined(selectedRecord)) {
throw new Error(
'Record ID and selected record are required to export note to PDF',
);
}
const filename = `${(selectedRecord.title || 'Untitled Note').replace(/[<>:"/\\|?*]/g, '-')}`;
const handleExecute = async () => {
const initialBody = selectedRecord.bodyV2?.blocknote;
let parsedBody = [];
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(initialBody);
} catch {
// oxlint-disable-next-line no-console
console.warn(
`Failed to parse body for record ${recordId}, for rich text version 'v2'`,
);
// oxlint-disable-next-line no-console
console.warn(initialBody);
}
const { exportBlockNoteEditorToPdf } = await import(
'@/command-menu-item/record/single-record/utils/exportBlockNoteEditorToPdf'
);
await exportBlockNoteEditorToPdf(parsedBody, filename);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,30 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useExportSingleRecord } from '@/object-record/record-show/hooks/useExportSingleRecord';
import { isDefined } from 'twenty-shared/utils';
export const ExportSingleRecordCommand = () => {
const { objectMetadataItem, currentViewId, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (
!isDefined(currentViewId) ||
!isDefined(recordId) ||
!isDefined(objectMetadataItem)
) {
throw new Error(
'Current view ID, record ID, and object metadata are required to export single record',
);
}
const filename = `${objectMetadataItem.nameSingular}.csv`;
const { download } = useExportSingleRecord({
filename,
objectMetadataItem,
recordId,
});
return <HeadlessEngineCommandWrapperEffect execute={download} />;
};
@@ -0,0 +1,24 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { isDefined } from 'twenty-shared/utils';
export const NavigateToNextRecordSingleRecordCommand = () => {
const { objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (!isDefined(recordId) || !isDefined(objectMetadataItem)) {
throw new Error(
'Record ID and object metadata are required to navigate to next record',
);
}
const { navigateToNextRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
return <HeadlessEngineCommandWrapperEffect execute={navigateToNextRecord} />;
};
@@ -0,0 +1,26 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { isDefined } from 'twenty-shared/utils';
export const NavigateToPreviousRecordSingleRecordCommand = () => {
const { objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (!isDefined(recordId) || !isDefined(objectMetadataItem)) {
throw new Error(
'Record ID and object metadata are required to navigate to previous record',
);
}
const { navigateToPreviousRecord } = useRecordShowPagePagination(
objectMetadataItem.nameSingular,
recordId,
);
return (
<HeadlessEngineCommandWrapperEffect execute={navigateToPreviousRecord} />
);
};
@@ -0,0 +1,42 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { isDefined } from 'twenty-shared/utils';
export const RemoveFromFavoritesSingleRecordCommand = () => {
const { selectedRecords, objectMetadataItem } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (!isDefined(recordId) || !isDefined(objectMetadataItem)) {
throw new Error(
'Record ID and object metadata are required to remove from favorites',
);
}
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const foundNavigationMenuItem = [
...navigationMenuItems,
...workspaceNavigationMenuItems,
].find(
(item) =>
item.targetRecordId === recordId &&
item.targetObjectMetadataId === objectMetadataItem.id,
);
const handleExecute = () => {
if (!isDefined(foundNavigationMenuItem)) {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,51 @@
import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useRemoveSelectedRecordsFromRecordBoard } from '@/object-record/record-board/hooks/useRemoveSelectedRecordsFromRecordBoard';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
export const RestoreSingleRecordCommand = () => {
const { recordIndexId, objectMetadataItem, selectedRecords } =
useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
if (
!isDefined(recordId) ||
!isDefined(recordIndexId) ||
!isDefined(objectMetadataItem)
) {
throw new Error(
'Record ID, record index ID, and object metadata are required to restore single record',
);
}
const { resetTableRowSelection } = useResetTableRowSelection(recordIndexId);
const { removeSelectedRecordsFromRecordBoard } =
useRemoveSelectedRecordsFromRecordBoard(recordIndexId);
const { restoreManyRecords } = useRestoreManyRecords({
objectNameSingular: objectMetadataItem.nameSingular,
});
const handleExecute = async () => {
removeSelectedRecordsFromRecordBoard();
resetTableRowSelection();
await restoreManyRecords({
idsToRestore: [recordId],
});
};
return (
<HeadlessConfirmationModalEngineCommandEffect
title={t`Restore Record`}
subtitle={t`Are you sure you want to restore this record?`}
confirmButtonText={t`Restore Record`}
confirmButtonAccent="default"
execute={handleExecute}
/>
);
};
@@ -0,0 +1,34 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isDefined } from 'twenty-shared/utils';
export const CancelDashboardSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord)) {
throw new Error('Selected record is required to cancel dashboard');
}
const pageLayoutId = selectedRecord.pageLayoutId;
const { closeSidePanelMenu } = useSidePanelMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleExecute = () => {
closeSidePanelMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,44 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useDuplicateDashboard } from '@/dashboards/hooks/useDuplicateDashboard';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DuplicateDashboardSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { duplicateDashboard } = useDuplicateDashboard();
const navigate = useNavigateApp();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
if (!isDefined(recordId)) {
throw new Error('Record ID is required to duplicate dashboard');
}
const handleExecute = async () => {
const result = await duplicateDashboard(recordId);
if (isDefined(result) && isNonEmptyString(result.id)) {
enqueueSuccessSnackBar({
message: t`Dashboard duplicated successfully`,
});
navigate(AppPath.RecordShowPage, {
objectNameSingular: CoreObjectNameSingular.Dashboard,
objectRecordId: result.id,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to duplicate dashboard`,
});
}
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,28 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { isDefined } from 'twenty-shared/utils';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditDashboardSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord)) {
throw new Error('Selected record is required to edit dashboard');
}
const pageLayoutId = selectedRecord.pageLayoutId;
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetLocationHash } = useResetLocationHash();
const handleExecute = () => {
setIsPageLayoutInEditMode(true);
resetLocationHash();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,35 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isDefined } from 'twenty-shared/utils';
export const SaveDashboardSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord)) {
throw new Error('Selected record is required to save dashboard');
}
const pageLayoutId = selectedRecord.pageLayoutId;
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeSidePanelMenu } = useSidePanelMenu();
const handleExecute = async () => {
const result = await savePageLayout();
if (result.status === 'successful') {
closeSidePanelMenu();
setIsPageLayoutInEditMode(false);
}
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,38 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useResetDraftPageLayoutToPersistedPageLayout } from '@/page-layout/hooks/useResetDraftPageLayoutToPersistedPageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isDefined } from 'twenty-shared/utils';
export const CancelRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem)) {
throw new Error(
'Object metadata item is required to cancel record page layout',
);
}
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { closeSidePanelMenu } = useSidePanelMenu();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetDraftPageLayoutToPersistedPageLayout } =
useResetDraftPageLayoutToPersistedPageLayout(pageLayoutId);
const handleExecute = () => {
closeSidePanelMenu();
resetDraftPageLayoutToPersistedPageLayout();
setIsPageLayoutInEditMode(false);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,32 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { isDefined } from 'twenty-shared/utils';
import { useResetLocationHash } from 'twenty-ui/utilities';
export const EditRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem)) {
throw new Error(
'Object metadata item is required to edit record page layout',
);
}
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { resetLocationHash } = useResetLocationHash();
const handleExecute = () => {
setIsPageLayoutInEditMode(true);
resetLocationHash();
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,44 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRecordPageLayoutIdFromRecordStoreOrThrow } from '@/page-layout/hooks/useRecordPageLayoutIdFromRecordStoreOrThrow';
import { useSaveFieldsWidgetGroups } from '@/page-layout/hooks/useSaveFieldsWidgetGroups';
import { useSavePageLayout } from '@/page-layout/hooks/useSavePageLayout';
import { useSetIsPageLayoutInEditMode } from '@/page-layout/hooks/useSetIsPageLayoutInEditMode';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isDefined } from 'twenty-shared/utils';
export const SaveRecordPageLayoutSingleRecordCommand = () => {
const { objectMetadataItem } = useMountedEngineCommandContext();
if (!isDefined(objectMetadataItem)) {
throw new Error(
'Object metadata item is required to save record page layout',
);
}
const { pageLayoutId } = useRecordPageLayoutIdFromRecordStoreOrThrow({
targetObjectNameSingular: objectMetadataItem.nameSingular,
});
const { savePageLayout } = useSavePageLayout(pageLayoutId);
const { saveFieldsWidgetGroups } = useSaveFieldsWidgetGroups();
const { setIsPageLayoutInEditMode } =
useSetIsPageLayoutInEditMode(pageLayoutId);
const { closeSidePanelMenu } = useSidePanelMenu();
const handleExecute = async () => {
const result = await savePageLayout();
if (result.status === 'successful') {
await saveFieldsWidgetGroups(pageLayoutId);
closeSidePanelMenu();
setIsPageLayoutInEditMode(false);
}
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,26 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeVersionWorkflowRunSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (
!isDefined(selectedRecord) ||
!isDefined(selectedRecord?.workflowVersion?.id)
) {
throw new Error('Selected record is required to see version workflow run');
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
objectRecordId: selectedRecord.workflowVersion.id,
}}
/>
);
};
@@ -0,0 +1,23 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeWorkflowWorkflowRunSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord) || !isDefined(selectedRecord?.workflow?.id)) {
return null;
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: selectedRecord.workflow.id,
}}
/>
);
};
@@ -0,0 +1,37 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useStopWorkflowRun } from '@/workflow/hooks/useStopWorkflowRun';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const StopWorkflowRunSingleRecordCommand = () => {
const { targetedRecordsRule, graphqlFilter } =
useMountedEngineCommandContext();
const { fetchAllRecords: fetchAllRecordIds } = useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
filter: isDefined(graphqlFilter) ? graphqlFilter : undefined,
limit: DEFAULT_QUERY_PAGE_SIZE,
recordGqlFields: { id: true },
});
const { stopWorkflowRun } = useStopWorkflowRun();
const handleExecute = async () => {
if (targetedRecordsRule.mode === 'selection') {
for (const selectedRecordId of targetedRecordsRule.selectedRecordIds) {
await stopWorkflowRun(selectedRecordId);
}
} else {
const records = await fetchAllRecordIds();
for (const record of records) {
await stopWorkflowRun(record.id);
}
}
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,59 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const SeeRunsWorkflowVersionSingleRecordCommandContent = ({
workflowId,
recordId,
}: {
workflowId: string;
recordId: string;
}) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
recordStore: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [recordId],
},
},
},
}}
/>
);
};
export const SeeRunsWorkflowVersionSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const selectedRecord = selectedRecords[0];
const workflowId = selectedRecord?.workflow?.id;
if (!isDefined(recordId) || !isDefined(workflowId)) {
throw new Error(
'Record ID and workflow ID are required to see runs workflow version',
);
}
return (
<SeeRunsWorkflowVersionSingleRecordCommandContent
workflowId={workflowId}
recordId={recordId}
/>
);
};
@@ -0,0 +1,47 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const SeeVersionsWorkflowVersionSingleRecordCommandContent = ({
workflowId,
}: {
workflowId: string;
}) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowVersion }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
},
}}
/>
);
};
export const SeeVersionsWorkflowVersionSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord) || !isDefined(selectedRecord.workflowId)) {
throw new Error(
'Selected record and workflow ID are required to see versions workflow version',
);
}
return (
<SeeVersionsWorkflowVersionSingleRecordCommandContent
workflowId={selectedRecord.workflowId}
/>
);
};
@@ -0,0 +1,25 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeWorkflowWorkflowVersionSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const selectedRecord = selectedRecords[0];
if (!isDefined(selectedRecord) || !isDefined(selectedRecord?.workflow?.id)) {
throw new Error(
'Selected record and workflow ID are required to see workflow workflow version',
);
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: selectedRecord.workflow.id,
}}
/>
);
};
@@ -0,0 +1,86 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal';
import { OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID } from '@/workflow/constants/OverrideWorkflowDraftConfirmationModalId';
import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useState } from 'react';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const UseAsDraftWorkflowVersionSingleRecordCommandContent = ({
workflowId,
workflowVersionId,
}: {
workflowId: string;
workflowVersionId: string;
}) => {
const { openModal } = useModal();
const workflow = useWorkflowWithCurrentVersion(workflowId);
const { createDraftFromWorkflowVersion } =
useCreateDraftFromWorkflowVersion();
const navigate = useNavigateApp();
const [hasNavigated, setHasNavigated] = useState(false);
const hasAlreadyDraftVersion =
workflow?.versions.some((version) => version.status === 'DRAFT') || false;
const handleExecute = () => {
if (!isDefined(workflow) || hasNavigated) {
return;
}
if (hasAlreadyDraftVersion) {
openModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID);
} else {
const executeCommandWithoutWaiting = async () => {
await createDraftFromWorkflowVersion({
workflowId,
workflowVersionIdToCopy: workflowVersionId,
});
navigate(AppPath.RecordShowPage, {
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowId,
});
setHasNavigated(true);
};
executeCommandWithoutWaiting();
}
};
return (
<>
<HeadlessEngineCommandWrapperEffect execute={handleExecute} />
<OverrideWorkflowDraftConfirmationModal
workflowId={workflowId}
workflowVersionIdToCopy={workflowVersionId}
/>
</>
);
};
export const UseAsDraftWorkflowVersionSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const workflowVersion = useWorkflowVersion(recordId ?? '');
if (!recordId || !isDefined(workflowVersion?.workflow?.id)) {
throw new Error(
'Record ID and workflow ID are required to use as draft workflow version',
);
}
return (
<UseAsDraftWorkflowVersionSingleRecordCommandContent
workflowId={workflowVersion.workflow.id}
workflowVersionId={workflowVersion.id}
/>
);
};
@@ -0,0 +1,32 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-shared/utils';
export const ActivateWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to activate workflow');
}
const handleExecute = () => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
activateWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowId: workflowWithCurrentVersion.id,
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,30 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useSidePanelWorkflowNavigation } from '@/side-panel/pages/workflow/hooks/useSidePanelWorkflowNavigation';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-shared/utils';
export const AddNodeWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
const { openWorkflowCreateStepInSidePanel } =
useSidePanelWorkflowNavigation();
if (!isDefined(recordId)) {
throw new Error('Record ID is required to add node workflow');
}
const handleExecute = () => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
openWorkflowCreateStepInSidePanel(workflowWithCurrentVersion.id);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,31 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-shared/utils';
export const DeactivateWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to deactivate workflow');
}
const handleExecute = () => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
deactivateWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,31 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-shared/utils';
export const DiscardDraftWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { deleteOneWorkflowVersion } = useDeleteOneWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to discard draft workflow');
}
const handleExecute = () => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
deleteOneWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,55 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useDuplicateWorkflow } from '@/workflow/hooks/useDuplicateWorkflow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const DuplicateWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const workflow = useWorkflowWithCurrentVersion(recordId ?? '');
const { duplicateWorkflow } = useDuplicateWorkflow();
const navigate = useNavigateApp();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const { t } = useLingui();
if (!isDefined(recordId)) {
throw new Error('Record ID is required to duplicate workflow');
}
const handleExecute = async () => {
if (!isDefined(workflow) || !isDefined(workflow.currentVersion)) {
return;
}
const result = await duplicateWorkflow({
workflowIdToDuplicate: workflow.id,
workflowVersionIdToCopy: workflow.currentVersion.id,
});
if (isDefined(result) && isNonEmptyString(result.workflowId)) {
enqueueSuccessSnackBar({
message: t`Workflow duplicated successfully`,
});
navigate(AppPath.RecordShowPage, {
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: result.workflowId,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to duplicate workflow`,
});
}
};
return isDefined(workflow) ? (
<HeadlessEngineCommandWrapperEffect execute={handleExecute} />
) : null;
};
@@ -0,0 +1,33 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useActiveWorkflowVersion } from '@/workflow/hooks/useActiveWorkflowVersion';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeActiveVersionWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { workflowVersion, loading } = useActiveWorkflowVersion({
workflowId: recordId ?? '',
});
if (!isDefined(recordId)) {
throw new Error('Record ID is required to see active version workflow');
}
if (loading) {
return null;
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordShowPage}
params={{
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
objectRecordId: workflowVersion.id,
}}
/>
);
};
@@ -0,0 +1,35 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeRunsWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to see runs workflow');
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowRun }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
},
}}
/>
);
};
@@ -0,0 +1,35 @@
import { HeadlessNavigateEngineCommand } from '@/command-menu-item/engine-command/components/HeadlessNavigateEngineCommand';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { AppPath, ViewFilterOperand } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const SeeVersionsWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to see versions workflow');
}
return (
<HeadlessNavigateEngineCommand
to={AppPath.RecordIndexPage}
params={{ objectNamePlural: CoreObjectNamePlural.WorkflowVersion }}
queryParams={{
filter: {
workflow: {
[ViewFilterOperand.IS]: {
selectedRecordIds: [workflowWithCurrentVersion?.id],
},
},
},
}}
/>
);
};
@@ -0,0 +1,32 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { isDefined } from 'twenty-shared/utils';
export const TestWorkflowSingleRecordCommand = () => {
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { runWorkflowVersion } = useRunWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
recordId ?? '',
);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to test workflow');
}
const handleExecute = () => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
runWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowId: workflowWithCurrentVersion.id,
});
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,42 @@
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useMountedEngineCommandContext } from '@/command-menu-item/engine-command/hooks/useMountedEngineCommandContext';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { useTidyUpWorkflowVersion } from '@/workflow/workflow-version/hooks/useTidyUpWorkflowVersion';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
export const TidyUpWorkflowSingleRecordCommand = () => {
const store = useStore();
const { selectedRecords } = useMountedEngineCommandContext();
const recordId = selectedRecords[0]?.id;
const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion();
const instanceId = getWorkflowVisualizerComponentInstanceId({
recordId: recordId ?? '',
});
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow(instanceId);
if (!isDefined(recordId)) {
throw new Error('Record ID is required to tidy up workflow');
}
const handleExecute = async () => {
const workflowDiagramAtom = workflowDiagramComponentState.atomFamily({
instanceId,
});
const workflowDiagram = store.get(workflowDiagramAtom);
if (!isDefined(workflowDiagram)) {
return;
}
const workflowVersionId = await getUpdatableWorkflowVersion();
await tidyUpWorkflowVersion(workflowVersionId, workflowDiagram);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
};
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const EngineCommandComponentInstanceContext =
createComponentInstanceContext();
@@ -0,0 +1,9 @@
import { type MountedEngineCommandState } from '@/command-menu-item/engine-command/types/MountedEngineCommandContext';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const mountedEngineCommandsState = createAtomState<
Map<string, MountedEngineCommandState>
>({
key: 'mountedEngineCommandsState',
defaultValue: new Map(),
});
@@ -0,0 +1,19 @@
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import {
type Nullable,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import { type EngineComponentKey } from '~/generated-metadata/graphql';
export type MountedEngineCommandState = {
engineComponentKey: EngineComponentKey;
contextStoreInstanceId: string;
objectMetadataItem: Nullable<ObjectMetadataItem>;
currentViewId: Nullable<string>;
recordIndexId: Nullable<string>;
targetedRecordsRule: ContextStoreTargetedRecordsRule;
selectedRecords: ObjectRecord[];
graphqlFilter: Nullable<RecordGqlOperationFilter>;
};