8191 command k workflow trigger for selected record (#8315)

Closes #8191 


https://github.com/user-attachments/assets/694da229-cc91-4df2-97a0-49cd5dabcf12
This commit is contained in:
Raphaël Bosi
2024-11-05 13:37:29 +01:00
committed by GitHub
parent 0893774cc1
commit d1531aa1b6
44 changed files with 543 additions and 209 deletions

View File

@ -97,6 +97,7 @@ export const DeleteRecordsActionEffect = ({
useEffect(() => {
if (canDelete) {
addActionMenuEntry({
type: 'standard',
key: 'delete',
label: 'Delete',
position,

View File

@ -3,10 +3,12 @@ import {
displayedExportProgress,
useExportRecordData,
} from '@/action-menu/hooks/useExportRecordData';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { IconDatabaseExport } from 'twenty-ui';
import { useEffect } from 'react';
import { IconFileExport } from 'twenty-ui';
export const ExportRecordsActionEffect = ({
position,
@ -16,6 +18,9 @@ export const ExportRecordsActionEffect = ({
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
contextStoreNumberOfSelectedRecordsComponentState,
);
const { progress, download } = useExportRecordData({
delayMs: 100,
@ -26,10 +31,14 @@ export const ExportRecordsActionEffect = ({
useEffect(() => {
addActionMenuEntry({
type: 'standard',
key: 'export',
position,
label: displayedExportProgress(progress),
Icon: IconFileExport,
label: displayedExportProgress(
contextStoreNumberOfSelectedRecords > 0 ? 'selection' : 'all',
progress,
),
Icon: IconDatabaseExport,
accent: 'default',
onClick: () => download(),
});
@ -37,6 +46,14 @@ export const ExportRecordsActionEffect = ({
return () => {
removeActionMenuEntry('export');
};
}, [download, progress, addActionMenuEntry, removeActionMenuEntry, position]);
}, [
contextStoreNumberOfSelectedRecords,
download,
progress,
addActionMenuEntry,
removeActionMenuEntry,
position,
]);
return null;
};

View File

@ -44,6 +44,7 @@ export const ManageFavoritesActionEffect = ({
}
addActionMenuEntry({
type: 'standard',
key: 'manage-favorites',
label: isFavorite ? 'Remove from favorites' : 'Add to favorites',
position,

View File

@ -1,21 +1,20 @@
import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect';
import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect';
import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect';
import { WorkflowRunRecordActionEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
const globalRecordActionEffects = [ExportRecordsActionEffect];
const singleRecordActionEffects = [
ManageFavoritesActionEffect,
ExportRecordsActionEffect,
DeleteRecordsActionEffect,
];
const multipleRecordActionEffects = [
ExportRecordsActionEffect,
DeleteRecordsActionEffect,
];
const multipleRecordActionEffects = [DeleteRecordsActionEffect];
export const RecordActionMenuEntriesSetter = () => {
const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2(
@ -36,10 +35,6 @@ export const RecordActionMenuEntriesSetter = () => {
);
}
if (!contextStoreNumberOfSelectedRecords) {
return null;
}
const actions =
contextStoreNumberOfSelectedRecords === 1
? singleRecordActionEffects
@ -47,13 +42,25 @@ export const RecordActionMenuEntriesSetter = () => {
return (
<>
{actions.map((ActionEffect, index) => (
{globalRecordActionEffects.map((ActionEffect, index) => (
<ActionEffect
key={index}
position={index}
objectMetadataItem={objectMetadataItem}
/>
))}
{actions.map((ActionEffect, index) => (
<ActionEffect
key={index}
position={globalRecordActionEffects.length + index}
objectMetadataItem={objectMetadataItem}
/>
))}
{contextStoreNumberOfSelectedRecords === 1 && (
<WorkflowRunRecordActionEffect
objectMetadataItem={objectMetadataItem}
/>
)}
</>
);
};

View File

@ -0,0 +1,104 @@
import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries';
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 { useAllActiveWorkflowVersionsForObject } from '@/workflow/hooks/useAllActiveWorkflowVersionsForObject';
import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion';
import { useTheme } from '@emotion/react';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { IconSettingsAutomation, isDefined } from 'twenty-ui';
import { capitalize } from '~/utils/string/capitalize';
export const WorkflowRunRecordActionEffect = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries();
const contextStoreTargetedRecordsRule = useRecoilComponentValueV2(
contextStoreTargetedRecordsRuleComponentState,
);
const selectedRecordId =
contextStoreTargetedRecordsRule.mode === 'selection'
? contextStoreTargetedRecordsRule.selectedRecordIds[0]
: undefined;
const selectedRecord = useRecoilValue(
recordStoreFamilyState(selectedRecordId ?? ''),
);
const { records: activeWorkflowVersions } =
useAllActiveWorkflowVersionsForObject({
objectNameSingular: objectMetadataItem.nameSingular,
triggerType: 'MANUAL',
});
const { runWorkflowVersion } = useRunWorkflowVersion();
const { enqueueSnackBar } = useSnackBar();
const theme = useTheme();
useEffect(() => {
if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) {
return;
}
for (const [
index,
activeWorkflowVersion,
] of activeWorkflowVersions.entries()) {
addActionMenuEntry({
type: 'workflow-run',
key: `workflow-run-${activeWorkflowVersion.workflow.name}`,
label: capitalize(activeWorkflowVersion.workflow.name),
position: index,
Icon: IconSettingsAutomation,
onClick: async () => {
if (!isDefined(selectedRecord)) {
return;
}
await runWorkflowVersion(activeWorkflowVersion.id, selectedRecord);
enqueueSnackBar('', {
variant: SnackBarVariant.Success,
title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`,
icon: (
<IconSettingsAutomation
size={16}
color={theme.snackBar.success.color}
/>
),
});
},
});
}
return () => {
for (const activeWorkflowVersion of activeWorkflowVersions) {
removeActionMenuEntry(
`workflow-run-${activeWorkflowVersion.workflow.name}`,
);
}
};
}, [
activeWorkflowVersions,
addActionMenuEntry,
enqueueSnackBar,
objectMetadataItem,
removeActionMenuEntry,
runWorkflowVersion,
selectedRecord,
theme.snackBar.success.color,
]);
return null;
};

View File

@ -33,7 +33,7 @@ export const RecordIndexActionMenuBar = () => {
const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned);
if (pinnedEntries.length === 0) {
if (contextStoreNumberOfSelectedRecords === 0) {
return null;
}

View File

@ -5,6 +5,7 @@ import { RecoilRoot } from 'recoil';
import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@ -34,30 +35,33 @@ const meta: Meta<typeof RecordIndexActionMenuBar> = {
selectedRecordIds: ['1', '2', '3'],
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
3,
);
const map = new Map<string, ActionMenuEntry>();
map.set('delete', {
isPinned: true,
type: 'standard',
key: 'delete',
label: 'Delete',
position: 0,
Icon: IconTrash,
onClick: deleteMock,
});
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
new Map([
[
'delete',
{
isPinned: true,
key: 'delete',
label: 'Delete',
position: 0,
Icon: IconTrash,
onClick: deleteMock,
},
],
]),
map,
);
set(
isBottomBarOpenedComponentState.atomFamily({
instanceId: 'action-bar-story-action-menu',

View File

@ -21,6 +21,7 @@ const markAsDoneMock = jest.fn();
export const Default: Story = {
args: {
entry: {
type: 'standard',
key: 'delete',
label: 'Delete',
position: 0,
@ -33,6 +34,7 @@ export const Default: Story = {
export const WithDangerAccent: Story = {
args: {
entry: {
type: 'standard',
key: 'delete',
label: 'Delete',
position: 0,
@ -46,6 +48,7 @@ export const WithDangerAccent: Story = {
export const WithInteraction: Story = {
args: {
entry: {
type: 'standard',
key: 'markAsDone',
label: 'Mark as done',
position: 0,

View File

@ -7,6 +7,7 @@ import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIn
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui';
@ -29,43 +30,43 @@ const meta: Meta<typeof RecordIndexActionMenuDropdown> = {
),
{ x: 10, y: 10 },
);
const map = new Map<string, ActionMenuEntry>();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
new Map([
[
'delete',
{
key: 'delete',
label: 'Delete',
position: 0,
Icon: IconTrash,
onClick: deleteMock,
},
],
[
'markAsDone',
{
key: 'markAsDone',
label: 'Mark as done',
position: 1,
Icon: IconCheckbox,
onClick: markAsDoneMock,
},
],
[
'addToFavorites',
{
key: 'addToFavorites',
label: 'Add to favorites',
position: 2,
Icon: IconHeart,
onClick: addToFavoritesMock,
},
],
]),
map,
);
map.set('delete', {
type: 'standard',
key: 'delete',
label: 'Delete',
position: 0,
Icon: IconTrash,
onClick: deleteMock,
});
map.set('markAsDone', {
type: 'standard',
key: 'markAsDone',
label: 'Mark as done',
position: 1,
Icon: IconCheckbox,
onClick: markAsDoneMock,
});
map.set('addToFavorites', {
type: 'standard',
key: 'addToFavorites',
label: 'Add to favorites',
position: 2,
Icon: IconHeart,
onClick: addToFavoritesMock,
});
set(
extractComponentState(
isDropdownOpenComponentState,

View File

@ -5,6 +5,7 @@ import { RecoilRoot } from 'recoil';
import { RecordShowRightDrawerActionMenuBar } from '@/action-menu/components/RecordShowRightDrawerActionMenuBar';
import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
@ -42,44 +43,43 @@ const meta: Meta<typeof RecordShowRightDrawerActionMenuBar> = {
}),
1,
);
const map = new Map<string, ActionMenuEntry>();
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'story-action-menu',
}),
new Map([
[
'addToFavorites',
{
key: 'addToFavorites',
label: 'Add to favorites',
position: 0,
Icon: IconHeart,
onClick: addToFavoritesMock,
},
],
[
'export',
{
key: 'export',
label: 'Export',
position: 1,
Icon: IconFileExport,
onClick: exportMock,
},
],
[
'delete',
{
key: 'delete',
label: 'Delete',
position: 2,
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger' as MenuItemAccent,
},
],
]),
map,
);
map.set('addToFavorites', {
type: 'standard',
key: 'addToFavorites',
label: 'Add to favorites',
position: 0,
Icon: IconHeart,
onClick: addToFavoritesMock,
});
map.set('export', {
type: 'standard',
key: 'export',
label: 'Export',
position: 1,
Icon: IconFileExport,
onClick: exportMock,
});
map.set('delete', {
type: 'standard',
key: 'delete',
label: 'Delete',
position: 2,
Icon: IconTrash,
onClick: deleteMock,
accent: 'danger' as MenuItemAccent,
});
}}
>
<ActionMenuComponentInstanceContext.Provider

View File

@ -86,7 +86,7 @@ describe('csvDownloader', () => {
describe('displayedExportProgress', () => {
it.each([
[undefined, undefined, 'percentage', 'Export'],
[undefined, undefined, 'percentage', 'Export View as CSV'],
[20, 50, 'percentage', 'Export (40%)'],
[0, 100, 'number', 'Export (0)'],
[10, 10, 'percentage', 'Export (100%)'],
@ -96,7 +96,7 @@ describe('displayedExportProgress', () => {
'displays the export progress',
(exportedRecordCount, totalRecordCount, displayType, expected) => {
expect(
displayedExportProgress({
displayedExportProgress('all', {
exportedRecordCount,
totalRecordCount,
displayType: displayType as 'percentage' | 'number',

View File

@ -108,9 +108,12 @@ const percentage = (part: number, whole: number): number => {
return Math.round((part / whole) * 100);
};
export const displayedExportProgress = (progress?: ExportProgress): string => {
export const displayedExportProgress = (
mode: 'all' | 'selection' = 'all',
progress?: ExportProgress,
): string => {
if (isUndefinedOrNull(progress?.exportedRecordCount)) {
return 'Export';
return mode === 'all' ? 'Export View as CSV' : 'Export Selection as CSV';
}
if (

View File

@ -4,6 +4,7 @@ import { IconComponent } from 'twenty-ui';
import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent';
export type ActionMenuEntry = {
type: 'standard' | 'workflow-run';
key: string;
label: string;
position: number;