fix(workflow): use as draft with an existing draft and cross-object record page filters (#23524)
Fixes two workflow issues.
### "Use as draft" fails when a draft already exists
`UseAsDraftWorkflowVersionSingleRecordCommand` rendered its own
`OverrideWorkflowDraftConfirmationModal`. Headless commands are
unmounted by `HeadlessEngineCommandWrapperEffect` as soon as `execute`
resolves, so the modal was removed from the tree right after `openModal`
was called and the user saw nothing happen. This regressed when the
command was converted from a rendered `<Command onClick>` to a
self-unmounting effect.
The command now uses `HeadlessConfirmationModalEngineCommandEffect`, the
existing mechanism for headless commands that need a confirmation: it
opens the app-wide `CommandMenuConfirmationModalManager` and keeps the
command mounted until the modal emits its result.
`OverrideWorkflowDraftConfirmationModal`, its modal id and its config
state are deleted.
To keep the "Go to Draft" shortcut, the shared confirmation modal config
gains an optional `linkButton` rendered as a secondary link button that
emits a `cancel` result on click.
The command also resolved the workflow id through `useWorkflowVersion`,
which is `undefined` while the query is in flight, so it threw and
surfaced an error snackbar through the command error boundary. It now
reads the workflow id off the selected record like the sibling workflow
version commands, and waits for the workflow to load before deciding
whether a confirmation is needed.
### `workflow object doesn't have any "workflowId" field` when opening a
workflow from a version
`useRecordShowPagePagination` builds prev/next queries from the parent
view stored in `contextStoreRecordShowParentViewComponentState`.
Navigating from a workflow version record page to its workflow through
the relation chip keeps the workflow version parent view, whose relation
filter compiles to `workflowId: { in: [...] }` and is then sent against
the `workflow` object, which the API rejects.
`useQueryVariablesFromParentView` now ignores the parent view when
`parentViewObjectNameSingular` does not match the current object, which
covers every navigation path between record pages of different objects.
### Test
Added `useQueryVariablesFromParentView.test.tsx` covering both the
matching and mismatching parent view object.
Manually checked on a local instance: override with an existing draft,
"Go to Draft", cancel then re-trigger, and the no-existing-draft path,
plus navigating from a filtered workflow versions view to a workflow.
---------
Co-authored-by: Tom <tom@twenty.com>
This commit is contained in:
+18
-1
@@ -5,7 +5,10 @@ import {
|
||||
type CommandMenuConfirmationModalResult,
|
||||
type CommandMenuConfirmationModalResultBrowserEventDetail,
|
||||
} from 'twenty-shared/types';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import {
|
||||
ConfirmationModal,
|
||||
StyledCenteredButton,
|
||||
} from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
@@ -52,6 +55,8 @@ export const CommandMenuConfirmationModalManager = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkButton = commandMenuItemConfirmationModalConfig.linkButton;
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
modalInstanceId={COMMAND_MENU_CONFIRMATION_MODAL_INSTANCE_ID}
|
||||
@@ -65,6 +70,18 @@ export const CommandMenuConfirmationModalManager = () => {
|
||||
confirmButtonAccent={
|
||||
commandMenuItemConfirmationModalConfig.confirmButtonAccent
|
||||
}
|
||||
AdditionalButtons={
|
||||
isDefined(linkButton) ? (
|
||||
<StyledCenteredButton
|
||||
to={linkButton.to}
|
||||
onClick={() => emitConfirmationResult('cancel')}
|
||||
variant="secondary"
|
||||
title={linkButton.title}
|
||||
fullWidth
|
||||
justify="center"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -4,12 +4,18 @@ import { type ConfirmationModalCaller } from 'twenty-shared/types';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { type ButtonAccent } from 'twenty-ui/input';
|
||||
|
||||
export type CommandMenuItemConfirmationModalLinkButton = {
|
||||
title: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type CommandMenuItemConfirmationModalConfig = {
|
||||
caller: ConfirmationModalCaller;
|
||||
title: string;
|
||||
subtitle: ReactNode;
|
||||
confirmButtonText?: string;
|
||||
confirmButtonAccent?: ButtonAccent;
|
||||
linkButton?: CommandMenuItemConfirmationModalLinkButton;
|
||||
};
|
||||
|
||||
export const commandMenuItemConfirmationModalConfigState =
|
||||
|
||||
+5
@@ -3,6 +3,7 @@ import { type ReactNode, useEffect } from 'react';
|
||||
|
||||
import { COMMAND_MENU_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME } from 'twenty-shared/constants';
|
||||
import { useCommandMenuConfirmationModal } from '@/command-menu-item/confirmation-modal/hooks/useCommandMenuConfirmationModal';
|
||||
import { type CommandMenuItemConfirmationModalLinkButton } from '@/command-menu-item/confirmation-modal/states/commandMenuItemConfirmationModalState';
|
||||
import { type CommandMenuConfirmationModalResultBrowserEventDetail } from 'twenty-shared/types';
|
||||
import { useUnmountCommand } from '@/command-menu-item/engine-command/hooks/useUnmountEngineCommand';
|
||||
import { CommandComponentInstanceContext } from '@/command-menu-item/engine-command/states/contexts/CommandComponentInstanceContext';
|
||||
@@ -14,6 +15,7 @@ export type HeadlessConfirmationModalEngineCommandEffectProps = {
|
||||
subtitle: ReactNode;
|
||||
confirmButtonText: string;
|
||||
confirmButtonAccent?: ButtonAccent;
|
||||
linkButton?: CommandMenuItemConfirmationModalLinkButton;
|
||||
execute: () => void | Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -22,6 +24,7 @@ export const HeadlessConfirmationModalEngineCommandEffect = ({
|
||||
subtitle,
|
||||
confirmButtonText,
|
||||
confirmButtonAccent = 'danger',
|
||||
linkButton,
|
||||
execute,
|
||||
}: HeadlessConfirmationModalEngineCommandEffectProps) => {
|
||||
const { isInitializedRef, setIsInitialized } =
|
||||
@@ -46,6 +49,7 @@ export const HeadlessConfirmationModalEngineCommandEffect = ({
|
||||
subtitle,
|
||||
confirmButtonText,
|
||||
confirmButtonAccent,
|
||||
linkButton,
|
||||
});
|
||||
}, [
|
||||
isInitializedRef,
|
||||
@@ -56,6 +60,7 @@ export const HeadlessConfirmationModalEngineCommandEffect = ({
|
||||
subtitle,
|
||||
confirmButtonText,
|
||||
confirmButtonAccent,
|
||||
linkButton,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+39
-44
@@ -1,14 +1,11 @@
|
||||
import { HeadlessConfirmationModalEngineCommandEffect } from '@/command-menu-item/engine-command/components/HeadlessConfirmationModalEngineCommandEffect';
|
||||
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
|
||||
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal';
|
||||
import { OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID } from '@/workflow/constants/OverrideWorkflowDraftConfirmationModalId';
|
||||
import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion';
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { useState } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getAppPath, isDefined } from 'twenty-shared/utils';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
const UseAsDraftWorkflowVersionSingleRecordCommandContent = ({
|
||||
@@ -18,60 +15,58 @@ const UseAsDraftWorkflowVersionSingleRecordCommandContent = ({
|
||||
workflowId: string;
|
||||
workflowVersionId: string;
|
||||
}) => {
|
||||
const { openModal } = useModal();
|
||||
const { t } = useLingui();
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowId);
|
||||
const { createDraftFromWorkflowVersion } =
|
||||
useCreateDraftFromWorkflowVersion();
|
||||
const navigate = useNavigateApp();
|
||||
const [hasNavigated, setHasNavigated] = useState(false);
|
||||
|
||||
const hasAlreadyDraftVersion =
|
||||
workflow?.versions.some((version) => version.status === 'DRAFT') || false;
|
||||
workflow?.versions.some((version) => version.status === 'DRAFT') ?? false;
|
||||
|
||||
const handleExecute = () => {
|
||||
if (!isDefined(workflow) || hasNavigated) {
|
||||
return;
|
||||
}
|
||||
const handleExecute = async () => {
|
||||
await createDraftFromWorkflowVersion({
|
||||
workflowId,
|
||||
workflowVersionIdToCopy: workflowVersionId,
|
||||
});
|
||||
|
||||
if (hasAlreadyDraftVersion) {
|
||||
openModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID);
|
||||
} else {
|
||||
const executeCommandWithoutWaiting = async () => {
|
||||
await createDraftFromWorkflowVersion({
|
||||
workflowId,
|
||||
workflowVersionIdToCopy: workflowVersionId,
|
||||
});
|
||||
|
||||
navigate(AppPath.RecordShowPage, {
|
||||
objectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
objectRecordId: workflowId,
|
||||
});
|
||||
|
||||
setHasNavigated(true);
|
||||
};
|
||||
|
||||
executeCommandWithoutWaiting();
|
||||
}
|
||||
navigate(AppPath.RecordShowPage, {
|
||||
objectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
objectRecordId: workflowId,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isDefined(workflow)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasAlreadyDraftVersion) {
|
||||
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeadlessEngineCommandWrapperEffect execute={handleExecute} />
|
||||
<OverrideWorkflowDraftConfirmationModal
|
||||
workflowId={workflowId}
|
||||
workflowVersionIdToCopy={workflowVersionId}
|
||||
/>
|
||||
</>
|
||||
<HeadlessConfirmationModalEngineCommandEffect
|
||||
title={t`A draft already exists`}
|
||||
subtitle={t`A draft already exists for this workflow. Are you sure you want to erase it?`}
|
||||
confirmButtonText={t`Override Draft`}
|
||||
linkButton={{
|
||||
title: t`Go to Draft`,
|
||||
to: getAppPath(AppPath.RecordShowPage, {
|
||||
objectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
objectRecordId: workflowId,
|
||||
}),
|
||||
}}
|
||||
execute={handleExecute}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const UseAsDraftWorkflowVersionSingleRecordCommand = () => {
|
||||
const { selectedRecords } = useHeadlessCommandContextApi();
|
||||
|
||||
const recordId = selectedRecords[0]?.id;
|
||||
const workflowVersion = useWorkflowVersion(recordId ?? '');
|
||||
const selectedRecord = selectedRecords[0];
|
||||
|
||||
if (!recordId || !isDefined(workflowVersion?.workflow?.id)) {
|
||||
if (!isDefined(selectedRecord) || !isDefined(selectedRecord.workflowId)) {
|
||||
throw new Error(
|
||||
'Record ID and workflow ID are required to use as draft workflow version',
|
||||
);
|
||||
@@ -79,8 +74,8 @@ export const UseAsDraftWorkflowVersionSingleRecordCommand = () => {
|
||||
|
||||
return (
|
||||
<UseAsDraftWorkflowVersionSingleRecordCommandContent
|
||||
workflowId={workflowVersion.workflow.id}
|
||||
workflowVersionId={workflowVersion.id}
|
||||
workflowId={selectedRecord.workflowId}
|
||||
workflowVersionId={selectedRecord.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
|
||||
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { useQueryVariablesFromParentView } from '@/views/hooks/useQueryVariablesFromParentView';
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
|
||||
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
|
||||
|
||||
const WORKFLOW_RECORD_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const workflowVersionObjectMetadataItem =
|
||||
getMockObjectMetadataItemOrThrow('workflowVersion');
|
||||
const workflowObjectMetadataItem = getMockObjectMetadataItemOrThrow('workflow');
|
||||
|
||||
const workflowRelationFieldMetadataItem =
|
||||
workflowVersionObjectMetadataItem.fields.find(
|
||||
(field) => field.name === 'workflow',
|
||||
);
|
||||
|
||||
if (!isDefined(workflowRelationFieldMetadataItem)) {
|
||||
throw new Error('Missing "workflow" field on the workflowVersion object');
|
||||
}
|
||||
|
||||
const workflowRecordFilter: RecordFilter = {
|
||||
id: 'record-filter-1',
|
||||
fieldMetadataId: workflowRelationFieldMetadataItem.id,
|
||||
operand: ViewFilterOperand.IS,
|
||||
value: JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: false,
|
||||
isCurrentRecordSelected: false,
|
||||
selectedRecordIds: [WORKFLOW_RECORD_ID],
|
||||
}),
|
||||
displayValue: 'My workflow',
|
||||
type: 'RELATION',
|
||||
label: 'Workflow',
|
||||
};
|
||||
|
||||
const renderUseQueryVariablesFromParentView = (
|
||||
objectMetadataItem: EnrichedObjectMetadataItem,
|
||||
) =>
|
||||
renderHook(() => useQueryVariablesFromParentView({ objectMetadataItem }), {
|
||||
wrapper: getJestMetadataAndApolloMocksWrapper({
|
||||
apolloMocks: [],
|
||||
onInitializeJotaiStore: (store) => {
|
||||
store.set(
|
||||
contextStoreRecordShowParentViewComponentState.atomFamily({
|
||||
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
|
||||
}),
|
||||
{
|
||||
parentViewComponentId: 'record-index-workflow-versions',
|
||||
parentViewObjectNameSingular:
|
||||
workflowVersionObjectMetadataItem.nameSingular,
|
||||
parentViewFilterGroups: [],
|
||||
parentViewFilters: [workflowRecordFilter],
|
||||
parentViewSorts: [],
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
describe('useQueryVariablesFromParentView', () => {
|
||||
it('should compute the filter when the parent view targets the same object', () => {
|
||||
const { result } = renderUseQueryVariablesFromParentView(
|
||||
workflowVersionObjectMetadataItem,
|
||||
);
|
||||
|
||||
expect(result.current.filter).toEqual({
|
||||
workflowId: { in: [WORKFLOW_RECORD_ID] },
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore the parent view when it targets another object', () => {
|
||||
const { result } = renderUseQueryVariablesFromParentView(
|
||||
workflowObjectMetadataItem,
|
||||
);
|
||||
|
||||
expect(result.current.filter).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,16 @@ export const useQueryVariablesFromParentView = ({
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const parentView =
|
||||
contextStoreRecordShowParentView?.parentViewObjectNameSingular ===
|
||||
objectMetadataItem.nameSingular
|
||||
? contextStoreRecordShowParentView
|
||||
: undefined;
|
||||
|
||||
const { filter, orderBy } = getQueryVariablesFromFiltersAndSorts({
|
||||
recordFilterGroups:
|
||||
contextStoreRecordShowParentView?.parentViewFilterGroups ?? [],
|
||||
recordFilters: contextStoreRecordShowParentView?.parentViewFilters ?? [],
|
||||
recordSorts: contextStoreRecordShowParentView?.parentViewSorts ?? [],
|
||||
recordFilterGroups: parentView?.parentViewFilterGroups ?? [],
|
||||
recordFilters: parentView?.parentViewFilters ?? [],
|
||||
recordSorts: parentView?.parentViewSorts ?? [],
|
||||
objectMetadataItem,
|
||||
objectMetadataItems,
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
@@ -39,7 +44,7 @@ export const useQueryVariablesFromParentView = ({
|
||||
});
|
||||
|
||||
const isSoftDeleteFilterActive =
|
||||
contextStoreRecordShowParentView?.parentViewFilters.some((recordFilter) =>
|
||||
parentView?.parentViewFilters.some((recordFilter) =>
|
||||
isRecordFilterAboutSoftDelete({ recordFilter, objectMetadataItems }),
|
||||
) ?? false;
|
||||
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
ConfirmationModal,
|
||||
StyledCenteredButton,
|
||||
} from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useModal } from '@/ui/layout/modal/hooks/useModal';
|
||||
import { OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID } from '@/workflow/constants/OverrideWorkflowDraftConfirmationModalId';
|
||||
import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
|
||||
import { getAppPath } from 'twenty-shared/utils';
|
||||
import { useNavigateApp } from '~/hooks/useNavigateApp';
|
||||
|
||||
export const OverrideWorkflowDraftConfirmationModal = ({
|
||||
workflowId,
|
||||
workflowVersionIdToCopy,
|
||||
}: {
|
||||
workflowId: string;
|
||||
workflowVersionIdToCopy: string;
|
||||
}) => {
|
||||
const { closeModal } = useModal();
|
||||
|
||||
const { createDraftFromWorkflowVersion } =
|
||||
useCreateDraftFromWorkflowVersion();
|
||||
|
||||
const navigate = useNavigateApp();
|
||||
|
||||
const handleOverrideDraft = async () => {
|
||||
await createDraftFromWorkflowVersion({
|
||||
workflowId,
|
||||
workflowVersionIdToCopy,
|
||||
});
|
||||
|
||||
navigate(AppPath.RecordShowPage, {
|
||||
objectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
objectRecordId: workflowId,
|
||||
});
|
||||
};
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmationModal
|
||||
modalInstanceId={OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID}
|
||||
title={t`A draft already exists`}
|
||||
subtitle={t`A draft already exists for this workflow. Are you sure you want to erase it?`}
|
||||
onConfirmClick={handleOverrideDraft}
|
||||
confirmButtonText={t`Override Draft`}
|
||||
AdditionalButtons={
|
||||
<StyledCenteredButton
|
||||
to={getAppPath(AppPath.RecordShowPage, {
|
||||
objectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
objectRecordId: workflowId,
|
||||
})}
|
||||
onClick={() => {
|
||||
closeModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID);
|
||||
}}
|
||||
variant="secondary"
|
||||
title={t`Go to Draft`}
|
||||
fullWidth
|
||||
justify="center"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
export const OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID =
|
||||
'override-workflow-draft-confirmation-modal';
|
||||
Reference in New Issue
Block a user