Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,337 changes: 1,283 additions & 54 deletions app/package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"build": "npm run verify && cross-env NODE_ENV=development CT_ENV=development webpack",
"prod": "cross-env NODE_ENV=production CT_ENV=production webpack",
"verify": "npm run ts-check",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"test:watch": "vitest",
"ts-watch": "tsc --watch",
"ts-check": "tsc",
"generate:hooks": "npx simple-git-hooks"
Expand Down Expand Up @@ -85,6 +86,7 @@
"style-loader": "^2.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.0.4",
"vitest": "^4.1.4",
"webpack": "^5.78.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useStore } from 'hooks/useStore';
import { NodeStore } from 'stores/nodeStore/node-store';
import { ModalStore } from 'stores/modalStore/modal-store';
import { detectType, isAllowedToCreateNode, isAllowedToPasteCopy, isAllowedToPasteLink } from 'utils/node-utils';
import { ModalConfirmation } from 'components/Modals/ModalConfirmation';
import { handleDeleteIntent } from './handle-delete';

export type EventProps = {
id: string;
Expand Down Expand Up @@ -80,43 +80,7 @@ export function DialogEditorContextMenu({ id, onVisibilityChange }: { id: string

const onDeleteClicked = ({ props }: ItemParams<EventProps>) => {
if (!props) return;

const { id: nodeId, type: nodeType, parentId } = props;
const { isLink } = detectType(nodeType);

const proceedWithDelete = () => {
if (isLink) {
nodeStore.deleteLink(parentId);
} else {
nodeStore.deleteNodeCascadeById(nodeId);
}
};

const buttons = {
positiveType: 'danger',
positiveLabel: 'Confirm',
onPositive: proceedWithDelete,
negativeLabel: 'Cancel',
};

const title = `Are you sure you want to delete this ${isLink ? 'link' : 'node'}?`;
const message = isLink
? 'This action will delete the link and only this specific link.'
: "This action will delete the node and all it's children. Are you sure you want to do this?";

modalStore.setModelContent(
ModalConfirmation,
{
type: 'warning',
title,
body: message,
width: '30rem',
buttons,
disableOk: false,
},
'global1',
);

handleDeleteIntent({ props, nodeStore, modalStore });
hideAll();
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { vi, describe, it, expect } from 'vitest';

// The real ModalConfirmation pulls in antd, which we don't want to load in a
// Node test environment. It's only used as a component reference passed to
// modalStore.setModelContent, so a stub is fine.
vi.mock('components/Modals/ModalConfirmation', () => ({
ModalConfirmation: 'ModalConfirmationStub',
}));

// utils/node-utils transitively loads the MobX store singletons (which touch
// `document` at construction time). The handler only uses detectType, so we
// stub it directly here.
vi.mock('utils/node-utils', () => ({
detectType: (type: string | null) => ({
isCore: false,
isBaseCore: false,
isIsolatedCore: false,
isRoot: type === 'root',
isNode: type === 'node',
isResponse: type === 'response',
isLink: type === 'link',
}),
}));

import { handleDeleteIntent } from '../handle-delete';
import type { NodeStore } from 'stores/nodeStore/node-store';
import type { ModalStore } from 'stores/modalStore/modal-store';
import type { ElementNodeType, PromptNodeType } from 'types';

function makeResponse(overrides: Partial<ElementNodeType> = {}): ElementNodeType {
return {
type: 'response',
nextNodeIndex: -1,
auxiliaryLink: false,
responseText: '',
...overrides,
} as ElementNodeType;
}

function makePromptNode(index: number, branches: ElementNodeType[] = []): PromptNodeType {
return { type: 'node', index, branches } as PromptNodeType;
}

function buildFakes(promptNode: PromptNodeType, inboundLinks: ElementNodeType[]) {
const nodeStore = {
getNode: vi.fn((_id: string) => promptNode),
getInboundLinksToPromptNodeIndex: vi.fn((_i: number) => inboundLinks),
deleteNodeCascadeById: vi.fn(),
deleteLink: vi.fn(),
};
const modalStore = { setModelContent: vi.fn() };
return { nodeStore, modalStore };
}

describe('CT-128: delete flow opens the confirmation modal', () => {
it('opens the modal with an inbound-link warning when deleting a linked-to prompt node', () => {
const linker = makeResponse({ nextNodeIndex: 5, auxiliaryLink: true });
const target = makePromptNode(5, []);
const { nodeStore, modalStore } = buildFakes(target, [linker]);

handleDeleteIntent({
props: { id: 'prompt-5-id', type: 'node', parentId: 'parent-id' },
nodeStore: nodeStore as unknown as NodeStore,
modalStore: modalStore as unknown as ModalStore,
});

expect(modalStore.setModelContent).toHaveBeenCalledTimes(1);
const [componentArg, modalProps, globalModalId] = modalStore.setModelContent.mock.calls[0];

expect(componentArg).toBe('ModalConfirmationStub');
expect(globalModalId).toBe('global1');

const body = (modalProps as { body: string | string[] }).body;
expect(Array.isArray(body)).toBe(true);
const joined = (body as string[]).join(' ');
expect(joined).toMatch(/1 response node links to this prompt node/i);
expect(joined).toMatch(/End of Dialogue/);

// Confirming the modal must still trigger the cascade delete.
const buttons = (modalProps as { buttons: { onPositive: () => void } }).buttons;
buttons.onPositive();
expect(nodeStore.deleteNodeCascadeById).toHaveBeenCalledWith('prompt-5-id');
expect(nodeStore.deleteLink).not.toHaveBeenCalled();
});

it('opens the modal with the generic body when the prompt node has no inbound links', () => {
const target = makePromptNode(5, []);
const { nodeStore, modalStore } = buildFakes(target, []);

handleDeleteIntent({
props: { id: 'prompt-5-id', type: 'node', parentId: 'parent-id' },
nodeStore: nodeStore as unknown as NodeStore,
modalStore: modalStore as unknown as ModalStore,
});

const modalProps = modalStore.setModelContent.mock.calls[0][1] as { body: string | string[] };
expect(typeof modalProps.body).toBe('string');
expect(modalProps.body).toMatch(/delete the node and all it's children/);
});

it('opens the link-only modal and calls deleteLink when deleting a link', () => {
const target = makePromptNode(5, []);
const { nodeStore, modalStore } = buildFakes(target, []);

handleDeleteIntent({
props: { id: 'link-id', type: 'link', parentId: 'response-owner-id' },
nodeStore: nodeStore as unknown as NodeStore,
modalStore: modalStore as unknown as ModalStore,
});

const modalProps = modalStore.setModelContent.mock.calls[0][1] as {
body: string | string[];
buttons: { onPositive: () => void };
};
expect(modalProps.body).toBe('This action will delete the link and only this specific link.');

modalProps.buttons.onPositive();
expect(nodeStore.deleteLink).toHaveBeenCalledWith('response-owner-id');
expect(nodeStore.deleteNodeCascadeById).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ModalConfirmation } from 'components/Modals/ModalConfirmation';
import { detectType } from 'utils/node-utils';
import { buildDeleteConfirmationContent } from 'utils/delete-confirmation-utils';
import type { NodeStore } from 'stores/nodeStore/node-store';
import type { ModalStore } from 'stores/modalStore/modal-store';
import type { PromptNodeType } from 'types';

export type DeleteEventProps = { id: string; type: string; parentId: string };

export function handleDeleteIntent({
props,
nodeStore,
modalStore,
}: {
props: DeleteEventProps;
nodeStore: NodeStore;
modalStore: ModalStore;
}): void {
const { id: nodeId, type: nodeType, parentId } = props;
const { isLink, isNode } = detectType(nodeType);

const proceedWithDelete = () => {
if (isLink) {
nodeStore.deleteLink(parentId);
} else {
nodeStore.deleteNodeCascadeById(nodeId);
}
};

let inboundLinkCount = 0;
if (isNode) {
const promptNode = nodeStore.getNode(nodeId) as PromptNodeType | null;
if (promptNode) {
inboundLinkCount = nodeStore.getInboundLinksToPromptNodeIndex(promptNode.index).length;
}
}

const { title, body } = buildDeleteConfirmationContent({ isLink, inboundLinkCount });

modalStore.setModelContent(
ModalConfirmation,
{
type: 'warning',
title,
body,
width: '30rem',
buttons: {
positiveType: 'danger',
positiveLabel: 'Confirm',
onPositive: proceedWithDelete,
negativeLabel: 'Cancel',
},
disableOk: false,
},
'global1',
);
}
9 changes: 9 additions & 0 deletions app/src/stores/nodeStore/node-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from 'utils/conversation-utils';
import { ClipboardType, ConversationAssetType, ElementNodeType, OperationCallType, PromptNodeType } from 'types';
import { isElementNodeType, isPromptNodeType } from 'utils/node-utils';
import { findInboundLinksToPromptNodeIndex } from 'utils/node-link-utils';
import { ModalConfirmation } from 'components/Modals/ModalConfirmation';
import { findTreeNodeParentWithDataNodeId } from 'utils/custom-tree-data-utils';

Expand Down Expand Up @@ -1069,6 +1070,14 @@ class NodeStore {
}
}

getInboundLinksToPromptNodeIndex(indexToFind: number): ElementNodeType[] {
const { unsavedActiveConversationAsset: conversationAsset } = dataStore;
if (conversationAsset === null) return [];

const { roots, nodes } = conversationAsset.conversation;
return findInboundLinksToPromptNodeIndex(roots, nodes, indexToFind);
}

/*
* Ensures that any node that refers to an id specified now points to 'END OF DIALOG' (-1)
*/
Expand Down
116 changes: 116 additions & 0 deletions app/src/utils/__tests__/delete-confirmation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { describe, it, expect } from 'vitest';

import { findInboundLinksToPromptNodeIndex } from 'utils/node-link-utils';
import { buildDeleteConfirmationContent } from 'utils/delete-confirmation-utils';
import type { ElementNodeType, PromptNodeType } from 'types';

function makeResponse(overrides: Partial<ElementNodeType> = {}): ElementNodeType {
return {
type: 'response',
nextNodeIndex: -1,
auxiliaryLink: false,
responseText: '',
...overrides,
} as ElementNodeType;
}

function makeRoot(overrides: Partial<ElementNodeType> = {}): ElementNodeType {
return {
type: 'root',
nextNodeIndex: -1,
auxiliaryLink: false,
responseText: '',
...overrides,
} as ElementNodeType;
}

function makePromptNode(index: number, branches: ElementNodeType[]): PromptNodeType {
return {
type: 'node',
index,
branches,
} as PromptNodeType;
}

describe('CT-128: warn on prompt-node deletion when linked', () => {
describe('findInboundLinksToPromptNodeIndex', () => {
it('detects an inbound auxiliary link from a response branch', () => {
const linker = makeResponse({ nextNodeIndex: 5, auxiliaryLink: true });
const owner = makePromptNode(2, [linker]);
const target = makePromptNode(5, []);

const linkers = findInboundLinksToPromptNodeIndex([], [owner, target], 5);

expect(linkers).toHaveLength(1);
expect(linkers[0]).toBe(linker);
});

it('detects an inbound auxiliary link from a root', () => {
const rootLinker = makeRoot({ nextNodeIndex: 7, auxiliaryLink: true });
const target = makePromptNode(7, []);

const linkers = findInboundLinksToPromptNodeIndex([rootLinker], [target], 7);

expect(linkers).toHaveLength(1);
expect(linkers[0]).toBe(rootLinker);
});

it("ignores a structural parent (nextNodeIndex matches but auxiliaryLink is false)", () => {
const structural = makeResponse({ nextNodeIndex: 5, auxiliaryLink: false });
const owner = makePromptNode(2, [structural]);
const target = makePromptNode(5, []);

const linkers = findInboundLinksToPromptNodeIndex([], [owner, target], 5);

expect(linkers).toHaveLength(0);
});

it('returns an empty array when there are no inbound links', () => {
const responseToElsewhere = makeResponse({ nextNodeIndex: 99, auxiliaryLink: true });
const owner = makePromptNode(2, [responseToElsewhere]);
const target = makePromptNode(5, []);

const linkers = findInboundLinksToPromptNodeIndex([], [owner, target], 5);

expect(linkers).toHaveLength(0);
});
});

describe('buildDeleteConfirmationContent', () => {
it('produces the inbound-link warning body when a prompt node has linkers (the #128 fix)', () => {
const { title, body } = buildDeleteConfirmationContent({ isLink: false, inboundLinkCount: 2 });

// Pre-fix: the production delete handler returns the generic single-line body
// for every prompt-node deletion regardless of inbound links. This expectation
// encodes the new contract — multi-paragraph body that calls out the linkers.
expect(title).toBe('Are you sure you want to delete this node?');
expect(Array.isArray(body)).toBe(true);
const joined = (body as string[]).join(' ');
expect(joined).toMatch(/2 response nodes link to this prompt node/i);
expect(joined).toMatch(/End of Dialogue/);
});

it('uses singular phrasing when only one inbound link exists', () => {
const { body } = buildDeleteConfirmationContent({ isLink: false, inboundLinkCount: 1 });

const joined = (body as string[]).join(' ');
expect(joined).toMatch(/1 response node links to this prompt node/i);
expect(joined).toMatch(/break that link/i);
});

it('produces the original generic body when there are no inbound links', () => {
const { title, body } = buildDeleteConfirmationContent({ isLink: false, inboundLinkCount: 0 });

expect(title).toBe('Are you sure you want to delete this node?');
expect(typeof body).toBe('string');
expect(body).toMatch(/delete the node and all it's children/);
});

it('produces the link-only body when deleting a link', () => {
const { title, body } = buildDeleteConfirmationContent({ isLink: true, inboundLinkCount: 0 });

expect(title).toBe('Are you sure you want to delete this link?');
expect(body).toBe('This action will delete the link and only this specific link.');
});
});
});
Loading
Loading