Implement contextual actions for the workflows (#8814)

Implemented the following actions for the workflows:
- Test Workflow
- Discard Draft
- Activate Draft
- Activate Workflow Last Published Version
- Deactivate Workflow
- See Workflow Executions
- See Workflow Active Version
- See Workflow Versions History

And the following actions for the workflow versions:
- Use As Draft
- See Workflow Versions History
- See Workflow Executions

Video example:


https://github.com/user-attachments/assets/016956a6-6f2e-4de5-9605-d6e14526cbb3

A few of these actions are links to the relations of the workflow object
(link to a filtered table). To generalize this behavior, I will create
an hook named `useSeeRelationsActionSingleRecordAction` to automatically
generate links to a showPage if the relation is a Many To One or to a
filtered table if the relation is a One to Many for all the record
types.
I will also create actions which will allow to create a relation.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2024-12-03 12:09:51 +01:00
committed by GitHub
parent 7b2d9894f3
commit 32194a88b3
45 changed files with 2334 additions and 254 deletions

View File

@ -1,9 +1,9 @@
import { MultipleRecordsActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/multiple-records/components/MultipleRecordsActionMenuEntrySetterEffect';
import { NoSelectionActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/no-selection/components/NoSelectionActionMenuEntrySetterEffect';
import { SingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect';
import { SingleRecordActionMenuEntrySetter } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetter';
import { WorkflowRunRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionMenuEntrySetter';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
@ -32,30 +32,35 @@ const ActionEffects = ({
objectId: objectMetadataItemId,
});
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED');
return (
<>
{contextStoreNumberOfSelectedRecords === 0 && (
<NoSelectionActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
{contextStoreNumberOfSelectedRecords === 1 && (
<SingleRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
{contextStoreNumberOfSelectedRecords === 1 && isWorkflowEnabled && (
<WorkflowRunRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
{contextStoreNumberOfSelectedRecords > 1 && (
{contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0 && (
<NoSelectionActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
{contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 1 && (
<>
<SingleRecordActionMenuEntrySetter
objectMetadataItem={objectMetadataItem}
/>
{isWorkflowEnabled && (
<WorkflowRunRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
)}
</>
)}
{(contextStoreTargetedRecordsRule.mode === 'exclusion' ||
contextStoreTargetedRecordsRule.selectedRecordIds.length > 1) && (
<MultipleRecordsActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>

View File

@ -0,0 +1,132 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useDeleteMultipleRecordsAction } from '../useDeleteMultipleRecordsAction';
jest.mock('@/object-record/hooks/useDeleteManyRecords', () => ({
useDeleteManyRecords: () => ({
deleteManyRecords: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useDeleteFavorite', () => ({
useDeleteFavorite: () => ({
deleteFavorite: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useFavorites', () => ({
useFavorites: () => ({
sortedFavorites: [],
}),
}));
jest.mock('@/object-record/record-table/hooks/useRecordTable', () => ({
useRecordTable: () => ({
resetTableRowSelection: jest.fn(),
}),
}));
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
onInitializeRecoilSnapshot: ({ set }) => {
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: '1',
}),
3,
);
},
});
describe('useDeleteMultipleRecordsAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestMetadataAndApolloMocksWrapper>
);
it('should register delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteMultipleRecordsAction: useDeleteMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteMultipleRecordsAction.registerDeleteMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('delete-multiple-records'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('delete-multiple-records')?.position,
).toBe(1);
});
it('should unregister delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteMultipleRecordsAction: useDeleteMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteMultipleRecordsAction.registerDeleteMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useDeleteMultipleRecordsAction.unregisterDeleteMultipleRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,105 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useExportMultipleRecordsAction } from '../useExportMultipleRecordsAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useExportMultipleRecordsAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register export multiple records action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportMultipleRecordsAction: useExportMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportMultipleRecordsAction.registerExportMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('export-multiple-records'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('export-multiple-records')?.position,
).toBe(1);
});
it('should unregister export multiple records action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportMultipleRecordsAction: useExportMultipleRecordsAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportMultipleRecordsAction.registerExportMultipleRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useExportMultipleRecordsAction.unregisterExportMultipleRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -22,10 +22,8 @@ import { useCallback, useContext, useState } from 'react';
import { IconTrash, isDefined } from 'twenty-ui';
export const useDeleteMultipleRecordsAction = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
@ -106,7 +104,11 @@ export const useDeleteMultipleRecordsAction = ({
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteMultipleRecordsAction = () => {
const registerDeleteMultipleRecordsAction = ({
position,
}: {
position: number;
}) => {
if (canDelete) {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,

View File

@ -12,10 +12,8 @@ import {
} from '@/object-record/record-index/export/hooks/useExportRecords';
export const useExportMultipleRecordsAction = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
@ -27,7 +25,11 @@ export const useExportMultipleRecordsAction = ({
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const registerExportMultipleRecordsAction = () => {
const registerExportMultipleRecordsAction = ({
position,
}: {
position: number;
}) => {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,

View File

@ -1,5 +1,5 @@
import { useDeleteMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useDeleteMultipleRecordsAction';
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportMultipleRecordsAction';
import { useExportMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/hooks/useExportMultipleRecordsAction';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useMultipleRecordsActions = ({
@ -11,26 +11,24 @@ export const useMultipleRecordsActions = ({
registerDeleteMultipleRecordsAction,
unregisterDeleteMultipleRecordsAction,
} = useDeleteMultipleRecordsAction({
position: 0,
objectMetadataItem,
});
const {
registerExportViewNoSelectionRecordsAction,
unregisterExportViewNoSelectionRecordsAction,
} = useExportViewNoSelectionRecordAction({
position: 1,
registerExportMultipleRecordsAction,
unregisterExportMultipleRecordsAction,
} = useExportMultipleRecordsAction({
objectMetadataItem,
});
const registerMultipleRecordsActions = () => {
registerDeleteMultipleRecordsAction();
registerExportViewNoSelectionRecordsAction();
registerDeleteMultipleRecordsAction({ position: 1 });
registerExportMultipleRecordsAction({ position: 2 });
};
const unregisterMultipleRecordsActions = () => {
unregisterDeleteMultipleRecordsAction();
unregisterExportViewNoSelectionRecordsAction();
unregisterExportMultipleRecordsAction();
};
return {

View File

@ -0,0 +1,108 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useExportViewNoSelectionRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register export view action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportViewNoSelectionRecordAction:
useExportViewNoSelectionRecordAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportViewNoSelectionRecordAction.registerExportViewNoSelectionRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('export-view-no-selection'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('export-view-no-selection')
?.position,
).toBe(1);
});
it('should unregister export view action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useExportViewNoSelectionRecordAction:
useExportViewNoSelectionRecordAction({
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useExportViewNoSelectionRecordAction.registerExportViewNoSelectionRecordsAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useExportViewNoSelectionRecordAction.unregisterExportViewNoSelectionRecordsAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -12,10 +12,8 @@ import {
} from '@/object-record/record-index/export/hooks/useExportRecords';
export const useExportViewNoSelectionRecordAction = ({
position,
objectMetadataItem,
}: {
position: number;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
@ -27,7 +25,11 @@ export const useExportViewNoSelectionRecordAction = ({
filename: `${objectMetadataItem.nameSingular}.csv`,
});
const registerExportViewNoSelectionRecordsAction = () => {
const registerExportViewNoSelectionRecordsAction = ({
position,
}: {
position: number;
}) => {
addActionMenuEntry({
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.Global,

View File

@ -1,4 +1,4 @@
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportMultipleRecordsAction';
import { useExportViewNoSelectionRecordAction } from '@/action-menu/actions/record-actions/no-selection/hooks/useExportViewNoSelectionRecordAction';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const useNoSelectionRecordActions = ({
@ -10,12 +10,11 @@ export const useNoSelectionRecordActions = ({
registerExportViewNoSelectionRecordsAction,
unregisterExportViewNoSelectionRecordsAction,
} = useExportViewNoSelectionRecordAction({
position: 0,
objectMetadataItem,
});
const registerNoSelectionRecordActions = () => {
registerExportViewNoSelectionRecordsAction();
registerExportViewNoSelectionRecordsAction({ position: 1 });
};
const unregisterNoSelectionRecordActions = () => {

View File

@ -0,0 +1,26 @@
import { SingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/components/SingleRecordActionMenuEntrySetterEffect';
import { WorkflowSingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/workflow-actions/components/WorkflowSingleRecordActionMenuEntrySetterEffect';
import { WorkflowVersionsSingleRecordActionMenuEntrySetterEffect } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/components/WorkflowVersionsSingleRecordActionMenuEntrySetterEffect';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const SingleRecordActionMenuEntrySetter = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
return (
<>
<SingleRecordActionMenuEntrySetterEffect
objectMetadataItem={objectMetadataItem}
/>
{objectMetadataItem.nameSingular === CoreObjectNameSingular.Workflow && (
<WorkflowSingleRecordActionMenuEntrySetterEffect />
)}
{objectMetadataItem.nameSingular ===
CoreObjectNameSingular.WorkflowVersion && (
<WorkflowVersionsSingleRecordActionMenuEntrySetterEffect />
)}
</>
);
};

View File

@ -0,0 +1 @@
export const NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS = 2;

View File

@ -0,0 +1,121 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { RecoilRoot } from 'recoil';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useDeleteSingleRecordAction } from '../useDeleteSingleRecordAction';
jest.mock('@/object-record/hooks/useDeleteOneRecord', () => ({
useDeleteOneRecord: () => ({
deleteOneRecord: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useDeleteFavorite', () => ({
useDeleteFavorite: () => ({
deleteFavorite: jest.fn(),
}),
}));
jest.mock('@/favorites/hooks/useFavorites', () => ({
useFavorites: () => ({
sortedFavorites: [],
}),
}));
jest.mock('@/object-record/record-table/hooks/useRecordTable', () => ({
useRecordTable: () => ({
resetTableRowSelection: jest.fn(),
}),
}));
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
describe('useDeleteSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecoilRoot>
);
it('should register delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteSingleRecordAction: useDeleteSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteSingleRecordAction.registerDeleteSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('delete-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('delete-single-record')?.position,
).toBe(1);
});
it('should unregister delete action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeleteSingleRecordAction: useDeleteSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeleteSingleRecordAction.registerDeleteSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
act(() => {
result.current.useDeleteSingleRecordAction.unregisterDeleteSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,108 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
import { useManageFavoritesSingleRecordAction } from '../useManageFavoritesSingleRecordAction';
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
)!;
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
describe('useManageFavoritesSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register manage favorites action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useManageFavoritesSingleRecordAction:
useManageFavoritesSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useManageFavoritesSingleRecordAction.registerManageFavoritesSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('manage-favorites-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('manage-favorites-single-record')
?.position,
).toBe(1);
});
it('should unregister manage favorites action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useManageFavoritesSingleRecordAction:
useManageFavoritesSingleRecordAction({
recordId: 'record1',
objectMetadataItem: companyMockObjectMetadataItem,
}),
};
},
{ wrapper },
);
act(() => {
result.current.useManageFavoritesSingleRecordAction.registerManageFavoritesSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useManageFavoritesSingleRecordAction.unregisterManageFavoritesSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -4,7 +4,6 @@ import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@ -12,15 +11,14 @@ import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useCallback, useContext, useState } from 'react';
import { IconTrash, isDefined } from 'twenty-ui';
export const useDeleteSingleRecordAction = ({
position,
recordId,
objectMetadataItem,
}: {
position: number;
recordId: string;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
@ -39,39 +37,26 @@ export const useDeleteSingleRecordAction = ({
const { sortedFavorites: favorites } = useFavorites();
const { deleteFavorite } = useDeleteFavorite();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const { closeRightDrawer } = useRightDrawer();
const recordIdToDelete =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds?.[0]
: undefined;
const handleDeleteClick = useCallback(async () => {
if (!isDefined(recordIdToDelete)) {
return;
}
resetTableRowSelection();
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === recordIdToDelete,
(favorite) => favorite.recordId === recordId,
);
if (isDefined(foundFavorite)) {
deleteFavorite(foundFavorite.id);
}
await deleteOneRecord(recordIdToDelete);
await deleteOneRecord(recordId);
}, [
deleteFavorite,
deleteOneRecord,
favorites,
recordIdToDelete,
resetTableRowSelection,
recordId,
]);
const isRemoteObject = objectMetadataItem.isRemote;
@ -79,8 +64,12 @@ export const useDeleteSingleRecordAction = ({
const { isInRightDrawer, onActionExecutedCallback } =
useContext(ActionMenuContext);
const registerDeleteSingleRecordAction = () => {
if (isRemoteObject || !isDefined(recordIdToDelete)) {
const registerDeleteSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (isRemoteObject) {
return;
}

View File

@ -3,51 +3,42 @@ import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useCreateFavorite } from '@/favorites/hooks/useCreateFavorite';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useRecoilValue } from 'recoil';
import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui';
export const useManageFavoritesSingleRecordAction = ({
position,
recordId,
objectMetadataItem,
}: {
position: number;
recordId: string;
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const { sortedFavorites: favorites } = useFavorites();
const { createFavorite } = useCreateFavorite();
const { deleteFavorite } = useDeleteFavorite();
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId ?? ''),
);
const selectedRecord = useRecoilValue(recordStoreFamilyState(recordId));
const foundFavorite = favorites?.find(
(favorite) => favorite.recordId === selectedRecordId,
(favorite) => favorite.recordId === recordId,
);
const isFavorite = !!selectedRecordId && !!foundFavorite;
const isFavorite = !!foundFavorite;
const registerManageFavoritesSingleRecordAction = () => {
const registerManageFavoritesSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
return;
}

View File

@ -1,17 +1,33 @@
import { useDeleteSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useDeleteSingleRecordAction';
import { useManageFavoritesSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/hooks/useManageFavoritesSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useSingleRecordActions = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerManageFavoritesSingleRecordAction,
unregisterManageFavoritesSingleRecordAction,
} = useManageFavoritesSingleRecordAction({
position: 0,
recordId: selectedRecordId,
objectMetadataItem,
});
@ -19,13 +35,13 @@ export const useSingleRecordActions = ({
registerDeleteSingleRecordAction,
unregisterDeleteSingleRecordAction,
} = useDeleteSingleRecordAction({
position: 1,
recordId: selectedRecordId,
objectMetadataItem,
});
const registerSingleRecordActions = () => {
registerManageFavoritesSingleRecordAction();
registerDeleteSingleRecordAction();
registerManageFavoritesSingleRecordAction({ position: 1 });
registerDeleteSingleRecordAction({ position: 2 });
};
const unregisterSingleRecordActions = () => {

View File

@ -0,0 +1,21 @@
import { NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS } from '@/action-menu/actions/record-actions/single-record/constants/NumberOfStandardSingleRecordActionsOnAllObjects';
import { useEffect } from 'react';
import { useWorkflowSingleRecordActions } from '../hooks/useWorkflowSingleRecordActions';
export const WorkflowSingleRecordActionMenuEntrySetterEffect = () => {
const { registerSingleRecordActions, unregisterSingleRecordActions } =
useWorkflowSingleRecordActions();
useEffect(() => {
registerSingleRecordActions({
startPosition:
NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS + 1,
});
return () => {
unregisterSingleRecordActions();
};
}, [registerSingleRecordActions, unregisterSingleRecordActions]);
return null;
};

View File

@ -0,0 +1,123 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useActivateWorkflowDraftWorkflowSingleRecordAction } from '../useActivateWorkflowDraftWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
steps: [
{
id: 'stepId1',
},
{
id: 'stepId2',
},
],
},
}),
}));
describe('useActivateWorkflowDraftWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowDraftWorkflowSingleRecordAction:
useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.registerActivateWorkflowDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'activate-workflow-draft-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'activate-workflow-draft-single-record',
)?.position,
).toBe(1);
});
it('should unregister activate workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowDraftWorkflowSingleRecordAction:
useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.registerActivateWorkflowDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useActivateWorkflowDraftWorkflowSingleRecordAction.unregisterActivateWorkflowDraftWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,124 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction } from '../useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DEACTIVATED',
steps: [
{
id: 'stepId1',
},
{
id: 'stepId2',
},
],
},
lastPublishedVersionId: 'lastPublishedVersionId',
}),
}));
describe('useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction:
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'activate-workflow-last-published-version-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'activate-workflow-last-published-version-single-record',
)?.position,
).toBe(1);
});
it('should unregister activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction:
useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction.unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,113 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useDeactivateWorkflowWorkflowSingleRecordAction } from '../useDeactivateWorkflowWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'ACTIVE',
},
lastPublishedVersionId: 'lastPublishedVersionId',
}),
}));
describe('useDeactivateWorkflowWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register activate workflow last published version workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeactivateWorkflowWorkflowSingleRecordAction:
useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.registerDeactivateWorkflowWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get('deactivate-workflow-single-record'),
).toBeDefined();
expect(
result.current.actionMenuEntries.get('deactivate-workflow-single-record')
?.position,
).toBe(1);
});
it('should unregister deactivate workflow workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDeactivateWorkflowWorkflowSingleRecordAction:
useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.registerDeactivateWorkflowWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useDeactivateWorkflowWorkflowSingleRecordAction.unregisterDeactivateWorkflowWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,128 @@
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { expect } from '@storybook/test';
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
import { useDiscardDraftWorkflowSingleRecordAction } from '../useDiscardDraftWorkflowSingleRecordAction';
const JestMetadataAndApolloMocksWrapper = getJestMetadataAndApolloMocksWrapper({
apolloMocks: [],
});
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => ({
id: 'workflowId',
currentVersion: {
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
},
lastPublishedVersionId: 'lastPublishedVersionId',
versions: [
{
id: 'currentVersionId',
trigger: 'trigger',
status: 'DRAFT',
},
{
id: 'lastPublishedVersionId',
trigger: 'trigger',
status: 'ACTIVE',
},
],
}),
}));
describe('useDiscardDraftWorkflowSingleRecordAction', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<JestMetadataAndApolloMocksWrapper>
<JestObjectMetadataItemSetter>
<ContextStoreComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
<ActionMenuComponentInstanceContext.Provider
value={{
instanceId: '1',
}}
>
{children}
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</JestObjectMetadataItemSetter>
</JestMetadataAndApolloMocksWrapper>
);
it('should register discard workflow draft workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDiscardDraftWorkflowSingleRecordAction:
useDiscardDraftWorkflowSingleRecordAction({
workflowId: 'workflowId',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.registerDiscardDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
expect(result.current.actionMenuEntries.size).toBe(1);
expect(
result.current.actionMenuEntries.get(
'discard-workflow-draft-single-record',
),
).toBeDefined();
expect(
result.current.actionMenuEntries.get(
'discard-workflow-draft-single-record',
)?.position,
).toBe(1);
});
it('should unregister deactivate workflow workflow action', () => {
const { result } = renderHook(
() => {
const actionMenuEntries = useRecoilComponentValueV2(
actionMenuEntriesComponentState,
);
return {
actionMenuEntries,
useDiscardDraftWorkflowSingleRecordAction:
useDiscardDraftWorkflowSingleRecordAction({
workflowId: 'workflow1',
}),
};
},
{ wrapper },
);
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.registerDiscardDraftWorkflowSingleRecordAction(
{ position: 1 },
);
});
act(() => {
result.current.useDiscardDraftWorkflowSingleRecordAction.unregisterDiscardDraftWorkflowSingleRecordAction();
});
expect(result.current.actionMenuEntries.size).toBe(0);
});
});

View File

@ -0,0 +1,64 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPower, isDefined } from 'twenty-ui';
export const useActivateWorkflowDraftWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const registerActivateWorkflowDraftWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion?.currentVersion?.trigger) ||
!isDefined(workflowWithCurrentVersion.currentVersion?.steps)
) {
return;
}
const isDraft =
workflowWithCurrentVersion.currentVersion.status === 'DRAFT';
if (!isDraft) {
return;
}
addActionMenuEntry({
key: 'activate-workflow-draft-single-record',
label: 'Activate Draft',
position,
Icon: IconPower,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
activateWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowId: workflowWithCurrentVersion.id,
});
},
});
};
const unregisterActivateWorkflowDraftWorkflowSingleRecordAction = () => {
removeActionMenuEntry('activate-workflow-draft-single-record');
};
return {
registerActivateWorkflowDraftWorkflowSingleRecordAction,
unregisterActivateWorkflowDraftWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,61 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPower, isDefined } from 'twenty-ui';
export const useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
({ workflowId }: { workflowId: string }) => {
const { addActionMenuEntry, removeActionMenuEntry } =
useActionMenuEntries();
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const workflowWithCurrentVersion =
useWorkflowWithCurrentVersion(workflowId);
const registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
({ position }: { position: number }) => {
if (
!isDefined(workflowWithCurrentVersion) ||
!isDefined(workflowWithCurrentVersion.currentVersion.trigger) ||
!isDefined(workflowWithCurrentVersion.lastPublishedVersionId) ||
workflowWithCurrentVersion.currentVersion.status === 'ACTIVE' ||
!isDefined(workflowWithCurrentVersion.currentVersion?.steps) ||
workflowWithCurrentVersion.currentVersion?.steps.length === 0
) {
return;
}
addActionMenuEntry({
key: 'activate-workflow-last-published-version-single-record',
label: 'Activate last published version',
position,
Icon: IconPower,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
activateWorkflowVersion({
workflowVersionId:
workflowWithCurrentVersion.lastPublishedVersionId,
workflowId: workflowWithCurrentVersion.id,
});
},
});
};
const unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction =
() => {
removeActionMenuEntry(
'activate-workflow-last-published-version-single-record',
);
};
return {
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,55 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPlayerPause, isDefined } from 'twenty-ui';
export const useDeactivateWorkflowWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const isWorkflowActive =
isDefined(workflowWithCurrentVersion) &&
workflowWithCurrentVersion.currentVersion.status === 'ACTIVE';
const registerDeactivateWorkflowWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion) || !isWorkflowActive) {
return;
}
addActionMenuEntry({
key: 'deactivate-workflow-single-record',
label: 'Deactivate Workflow',
position,
Icon: IconPlayerPause,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
deactivateWorkflowVersion(workflowWithCurrentVersion.currentVersion.id);
},
});
};
const unregisterDeactivateWorkflowWorkflowSingleRecordAction = () => {
removeActionMenuEntry('deactivate-workflow-single-record');
};
return {
registerDeactivateWorkflowWorkflowSingleRecordAction,
unregisterDeactivateWorkflowWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,63 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconTrash, isDefined } from 'twenty-ui';
export const useDiscardDraftWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const { deleteOneWorkflowVersion } = useDeleteOneWorkflowVersion();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const registerDiscardDraftWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion) ||
workflowWithCurrentVersion.versions.length < 2
) {
return;
}
const isDraft =
workflowWithCurrentVersion.currentVersion.status === 'DRAFT';
if (!isDraft) {
return;
}
addActionMenuEntry({
key: 'discard-workflow-draft-single-record',
label: 'Discard Draft',
position,
Icon: IconTrash,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: () => {
deleteOneWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
});
},
});
};
const unregisterDiscardDraftWorkflowSingleRecordAction = () => {
removeActionMenuEntry('discard-workflow-draft-single-record');
};
return {
registerDiscardDraftWorkflowSingleRecordAction,
unregisterDiscardDraftWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,60 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useActiveWorkflowVersion } from '@/workflow/hooks/useActiveWorkflowVersion';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHistory, isDefined } from 'twenty-ui';
export const useSeeWorkflowActiveVersionWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflow = useRecoilValue(recordStoreFamilyState(workflowId));
const isDraft = workflow?.statuses?.includes('DRAFT');
const workflowActiveVersion = useActiveWorkflowVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowActiveVersionWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowActiveVersion) || !isDraft) {
return;
}
addActionMenuEntry({
key: 'see-workflow-active-version-single-record',
label: 'See active version',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistory,
onClick: () => {
navigate(
`/object/${CoreObjectNameSingular.WorkflowVersion}/${workflowActiveVersion.id}`,
);
},
});
};
const unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-active-version-single-record');
};
return {
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction,
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,66 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { IconHistoryToggle, isDefined } from 'twenty-ui';
export const useSeeWorkflowRunsWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowRunsWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowRun}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-runs-single-record',
label: 'See runs',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistoryToggle,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowRunsWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-runs-single-record');
};
return {
registerSeeWorkflowRunsWorkflowSingleRecordAction,
unregisterSeeWorkflowRunsWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,66 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { IconHistory, isDefined } from 'twenty-ui';
export const useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const navigate = useNavigate();
const registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowVersion}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-versions-history-single-record',
label: 'See versions history',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistory,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction = () => {
removeActionMenuEntry('see-workflow-versions-history-single-record');
};
return {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,60 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { IconPlayerPlay, isDefined } from 'twenty-ui';
export const useTestWorkflowWorkflowSingleRecordAction = ({
workflowId,
}: {
workflowId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const { runWorkflowVersion } = useRunWorkflowVersion();
const registerTestWorkflowWorkflowSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowWithCurrentVersion?.currentVersion?.trigger) ||
workflowWithCurrentVersion.currentVersion.trigger.type !== 'MANUAL' ||
isDefined(
workflowWithCurrentVersion.currentVersion.trigger.settings.objectType,
)
) {
return;
}
addActionMenuEntry({
key: 'test-workflow-single-record',
label: 'Test workflow',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconPlayerPlay,
onClick: () => {
runWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowName: workflowWithCurrentVersion.name,
});
},
});
};
const unregisterTestWorkflowWorkflowSingleRecordAction = () => {
removeActionMenuEntry('test-workflow-single-record');
};
return {
registerTestWorkflowWorkflowSingleRecordAction,
unregisterTestWorkflowWorkflowSingleRecordAction,
};
};

View File

@ -0,0 +1,127 @@
import { useActivateWorkflowDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateWorkflowDraftWorkflowSingleRecordAction';
import { useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction';
import { useDeactivateWorkflowWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDeactivateWorkflowWorkflowSingleRecordAction';
import { useDiscardDraftWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useDiscardDraftWorkflowSingleRecordAction';
import { useSeeWorkflowActiveVersionWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowActiveVersionWorkflowSingleRecordAction';
import { useSeeWorkflowRunsWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowRunsWorkflowSingleRecordAction';
import { useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction';
import { useTestWorkflowWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useTestWorkflowWorkflowSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useWorkflowSingleRecordActions = () => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds?.[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerTestWorkflowWorkflowSingleRecordAction,
unregisterTestWorkflowWorkflowSingleRecordAction,
} = useTestWorkflowWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction,
} = useActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerDeactivateWorkflowWorkflowSingleRecordAction,
unregisterDeactivateWorkflowWorkflowSingleRecordAction,
} = useDeactivateWorkflowWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowRunsWorkflowSingleRecordAction,
unregisterSeeWorkflowRunsWorkflowSingleRecordAction,
} = useSeeWorkflowRunsWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction,
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction,
} = useSeeWorkflowActiveVersionWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerActivateWorkflowDraftWorkflowSingleRecordAction,
unregisterActivateWorkflowDraftWorkflowSingleRecordAction,
} = useActivateWorkflowDraftWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const {
registerDiscardDraftWorkflowSingleRecordAction,
unregisterDiscardDraftWorkflowSingleRecordAction,
} = useDiscardDraftWorkflowSingleRecordAction({
workflowId: selectedRecordId,
});
const registerSingleRecordActions = ({
startPosition,
}: {
startPosition: number;
}) => {
registerTestWorkflowWorkflowSingleRecordAction({ position: startPosition });
registerDiscardDraftWorkflowSingleRecordAction({
position: startPosition + 1,
});
registerActivateWorkflowDraftWorkflowSingleRecordAction({
position: startPosition + 2,
});
registerActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction({
position: startPosition + 3,
});
registerDeactivateWorkflowWorkflowSingleRecordAction({
position: startPosition + 4,
});
registerSeeWorkflowRunsWorkflowSingleRecordAction({
position: startPosition + 5,
});
registerSeeWorkflowActiveVersionWorkflowSingleRecordAction({
position: startPosition + 6,
});
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
position: startPosition + 7,
});
};
const unregisterSingleRecordActions = () => {
unregisterTestWorkflowWorkflowSingleRecordAction();
unregisterActivateWorkflowLastPublishedVersionWorkflowSingleRecordAction();
unregisterDiscardDraftWorkflowSingleRecordAction();
unregisterActivateWorkflowDraftWorkflowSingleRecordAction();
unregisterDeactivateWorkflowWorkflowSingleRecordAction();
unregisterSeeWorkflowRunsWorkflowSingleRecordAction();
unregisterSeeWorkflowActiveVersionWorkflowSingleRecordAction();
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction();
};
return {
registerSingleRecordActions,
unregisterSingleRecordActions,
};
};

View File

@ -0,0 +1,21 @@
import { NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS } from '@/action-menu/actions/record-actions/single-record/constants/NumberOfStandardSingleRecordActionsOnAllObjects';
import { useWorkflowVersionsSingleRecordActions } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useWorkflowVersionsSingleRecordActions';
import { useEffect } from 'react';
export const WorkflowVersionsSingleRecordActionMenuEntrySetterEffect = () => {
const { registerSingleRecordActions, unregisterSingleRecordActions } =
useWorkflowVersionsSingleRecordActions();
useEffect(() => {
registerSingleRecordActions({
startPosition:
NUMBER_OF_STANDARD_SINGLE_RECORD_ACTIONS_ON_ALL_OBJECTS + 1,
});
return () => {
unregisterSingleRecordActions();
};
}, [registerSingleRecordActions, unregisterSingleRecordActions]);
return null;
};

View File

@ -0,0 +1,78 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import qs from 'qs';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { IconHistoryToggle, isDefined } from 'twenty-ui';
export const useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(
workflowVersion?.workflow.id,
);
const navigate = useNavigate();
const registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (!isDefined(workflowWithCurrentVersion)) {
return;
}
const filterQueryParams: FilterQueryParams = {
filter: {
workflow: {
[ViewFilterOperand.Is]: [workflowWithCurrentVersion.id],
},
workflowVersion: {
[ViewFilterOperand.Is]: [workflowVersionId],
},
},
};
const filterLinkHref = `/objects/${CoreObjectNamePlural.WorkflowRun}?${qs.stringify(
filterQueryParams,
)}`;
addActionMenuEntry({
key: 'see-workflow-executions-single-record',
label: 'See executions',
position,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
Icon: IconHistoryToggle,
onClick: () => {
navigate(filterLinkHref);
},
});
};
const unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction =
() => {
removeActionMenuEntry('see-workflow-executions-single-record');
};
return {
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
};
};

View File

@ -0,0 +1,27 @@
import { useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useRecoilValue } from 'recoil';
export const useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const {
registerSeeWorkflowVersionsHistoryWorkflowSingleRecordAction:
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowSingleRecordAction:
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowSingleRecordAction({
workflowId: workflowVersion?.workflow.id,
});
return {
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
};
};

View File

@ -0,0 +1,92 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { openOverrideWorkflowDraftConfirmationModalState } from '@/workflow/states/openOverrideWorkflowDraftConfirmationModalState';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { IconPencil, isDefined } from 'twenty-ui';
export const useUseAsDraftWorkflowVersionSingleRecordAction = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const workflowVersion = useRecoilValue(
recordStoreFamilyState(workflowVersionId),
);
const workflow = useWorkflowWithCurrentVersion(
workflowVersion?.workflow?.id ?? '',
);
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const setOpenOverrideWorkflowDraftConfirmationModal = useSetRecoilState(
openOverrideWorkflowDraftConfirmationModalState,
);
const registerUseAsDraftWorkflowVersionSingleRecordAction = ({
position,
}: {
position: number;
}) => {
if (
!isDefined(workflowVersion) ||
!isDefined(workflow) ||
!isDefined(workflow.statuses) ||
workflowVersion.status === 'DRAFT'
) {
return;
}
const hasAlreadyDraftVersion = workflow.statuses.includes('DRAFT');
addActionMenuEntry({
key: 'use-workflow-version-as-draft-single-record',
label: 'Use as draft',
position,
Icon: IconPencil,
type: ActionMenuEntryType.Standard,
scope: ActionMenuEntryScope.RecordSelection,
onClick: async () => {
if (hasAlreadyDraftVersion) {
setOpenOverrideWorkflowDraftConfirmationModal(true);
} else {
await createNewWorkflowVersion({
workflowId: workflowVersion.workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
}
},
ConfirmationModal: (
<OverrideWorkflowDraftConfirmationModal
draftWorkflowVersionId={workflow?.currentVersion?.id ?? ''}
workflowId={workflow?.id ?? ''}
workflowVersionUpdateInput={{
steps: workflowVersion.steps,
trigger: workflowVersion.trigger,
}}
/>
),
});
};
const unregisterUseAsDraftWorkflowVersionSingleRecordAction = () => {
removeActionMenuEntry('use-workflow-version-as-draft-single-record');
};
return {
registerUseAsDraftWorkflowVersionSingleRecordAction,
unregisterUseAsDraftWorkflowVersionSingleRecordAction,
};
};

View File

@ -0,0 +1,70 @@
import { useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction';
import { useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction';
import { useUseAsDraftWorkflowVersionSingleRecordAction } from '@/action-menu/actions/record-actions/single-record/workflow-version-actions/hooks/useUseAsDraftWorkflowVersionSingleRecordAction';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { isDefined } from 'twenty-ui';
export const useWorkflowVersionsSingleRecordActions = () => {
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds?.[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const {
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction,
} = useSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const {
registerUseAsDraftWorkflowVersionSingleRecordAction,
unregisterUseAsDraftWorkflowVersionSingleRecordAction,
} = useUseAsDraftWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const {
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction,
} = useSeeWorkflowExecutionsWorkflowVersionSingleRecordAction({
workflowVersionId: selectedRecordId,
});
const registerSingleRecordActions = ({
startPosition,
}: {
startPosition: number;
}) => {
registerUseAsDraftWorkflowVersionSingleRecordAction({
position: startPosition,
});
registerSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction({
position: startPosition + 1,
});
registerSeeWorkflowExecutionsWorkflowVersionSingleRecordAction({
position: startPosition + 2,
});
};
const unregisterSingleRecordActions = () => {
unregisterUseAsDraftWorkflowVersionSingleRecordAction();
unregisterSeeWorkflowVersionsHistoryWorkflowVersionSingleRecordAction();
unregisterSeeWorkflowExecutionsWorkflowVersionSingleRecordAction();
};
return {
registerSingleRecordActions,
unregisterSingleRecordActions,
};
};

View File

@ -6,13 +6,10 @@ import {
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useTheme } from '@emotion/react';
import { useRecoilValue } from 'recoil';
import { IconSettingsAutomation, isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
@ -33,8 +30,12 @@ export const useWorkflowRunRecordActions = ({
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
if (!isDefined(selectedRecordId)) {
throw new Error('Selected record ID is required');
}
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId ?? ''),
recordStoreFamilyState(selectedRecordId),
);
const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({
@ -44,10 +45,6 @@ export const useWorkflowRunRecordActions = ({
const { runWorkflowVersion } = useRunWorkflowVersion();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const registerWorkflowRunRecordActions = () => {
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
return;
@ -57,6 +54,9 @@ export const useWorkflowRunRecordActions = ({
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
if (!isDefined(activeWorkflowVersion.workflow)) {
continue;
}
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
@ -72,19 +72,9 @@ export const useWorkflowRunRecordActions = ({
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
workflowName: name,
payload: selectedRecord,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${name} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
});
},
});
}

View File

@ -3,14 +3,11 @@ import {
ActionMenuEntryScope,
ActionMenuEntryType,
} from '@/action-menu/types/ActionMenuEntry';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import { IconSettingsAutomation } from 'twenty-ui';
import { IconSettingsAutomation, isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const useWorkflowRunActions = () => {
@ -24,10 +21,6 @@ export const useWorkflowRunActions = () => {
const { runWorkflowVersion } = useRunWorkflowVersion();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const addWorkflowRunActions = () => {
if (!isWorkflowEnabled) {
return;
@ -37,7 +30,12 @@ export const useWorkflowRunActions = () => {
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
if (!isDefined(activeWorkflowVersion.workflow)) {
continue;
}
const name = capitalize(activeWorkflowVersion.workflow.name);
addActionMenuEntry({
type: ActionMenuEntryType.WorkflowRun,
key: `workflow-run-${activeWorkflowVersion.id}`,
@ -48,17 +46,7 @@ export const useWorkflowRunActions = () => {
onClick: async () => {
await runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${name} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
workflowName: name,
});
},
});

View File

@ -7,14 +7,17 @@ import {
} from '@/ui/layout/modal/components/ConfirmationModal';
import { openOverrideWorkflowDraftConfirmationModalState } from '@/workflow/states/openOverrideWorkflowDraftConfirmationModalState';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { useNavigate } from 'react-router-dom';
import { useRecoilState } from 'recoil';
export const OverrideWorkflowDraftConfirmationModal = ({
draftWorkflowVersionId,
workflowVersionUpdateInput,
workflowId,
}: {
draftWorkflowVersionId: string;
workflowVersionUpdateInput: Pick<WorkflowVersion, 'trigger' | 'steps'>;
workflowId: string;
}) => {
const [
openOverrideWorkflowDraftConfirmationModal,
@ -26,11 +29,15 @@ export const OverrideWorkflowDraftConfirmationModal = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const navigate = useNavigate();
const handleOverrideDraft = async () => {
await updateOneWorkflowVersion({
idToUpdate: draftWorkflowVersionId,
updateOneRecordInput: workflowVersionUpdateInput,
});
navigate(buildShowPageURL(CoreObjectNameSingular.Workflow, workflowId));
};
return (

View File

@ -71,6 +71,7 @@ export const RecordShowPageWorkflowHeader = ({
await runWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowName: workflowWithCurrentVersion.name,
});
enqueueSnackBar('', {

View File

@ -131,6 +131,7 @@ export const RecordShowPageWorkflowVersionHeader = ({
{isDefined(workflowVersion) && isDefined(draftWorkflowVersion) ? (
<OverrideWorkflowDraftConfirmationModal
draftWorkflowVersionId={draftWorkflowVersion.id}
workflowId={workflowVersion.workflowId}
workflowVersionUpdateInput={{
steps: workflowVersion.steps,
trigger: workflowVersion.trigger,

View File

@ -0,0 +1,43 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow';
export const useActiveWorkflowVersion = (workflowId: string) => {
const { records: workflowVersions } = useFindManyRecords<
WorkflowVersion & {
workflow: Omit<Workflow, 'versions'> & {
versions: Array<{ __typename: string }>;
};
}
>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowId,
},
status: {
eq: 'ACTIVE',
},
},
recordGqlFields: {
id: true,
name: true,
createdAt: true,
updatedAt: true,
workflowId: true,
trigger: true,
steps: true,
status: true,
workflow: {
id: true,
name: true,
statuses: true,
versions: {
totalCount: true,
},
},
},
});
return workflowVersions?.[0];
};

View File

@ -1,10 +1,15 @@
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { RUN_WORKFLOW_VERSION } from '@/workflow/graphql/mutations/runWorkflowVersion';
import { useMutation } from '@apollo/client';
import { useTheme } from '@emotion/react';
import { IconSettingsAutomation } from 'twenty-ui';
import {
RunWorkflowVersionMutation,
RunWorkflowVersionMutationVariables,
} from '~/generated/graphql';
import { capitalize } from '~/utils/string/capitalize';
export const useRunWorkflowVersion = () => {
const apolloMetadataClient = useApolloMetadataClient();
@ -15,16 +20,33 @@ export const useRunWorkflowVersion = () => {
client: apolloMetadataClient,
});
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
const runWorkflowVersion = async ({
workflowVersionId,
workflowName,
payload,
}: {
workflowVersionId: string;
workflowName: string;
payload?: Record<string, any>;
}) => {
await mutate({
variables: { input: { workflowVersionId, payload } },
});
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(workflowName)} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
});
};
return { runWorkflowVersion };

View File

@ -17,6 +17,7 @@ export const useWorkflowWithCurrentVersion = (
id: true,
name: true,
statuses: true,
lastPublishedVersionId: true,
versions: true,
},
skip: !isDefined(workflowId),