diff --git a/packages/components/src/DragUtils.test.ts b/packages/components/src/DragUtils.test.ts
index 11a2549eb0..2b779835ce 100644
--- a/packages/components/src/DragUtils.test.ts
+++ b/packages/components/src/DragUtils.test.ts
@@ -1,7 +1,7 @@
import DragUtils from './DragUtils';
function makeItems(count = 5) {
- const items = [];
+ const items: number[] = [];
for (let i = 0; i < count; i += 1) {
items.push(i);
diff --git a/packages/dashboard-core-plugins/src/linker/Linker.test.tsx b/packages/dashboard-core-plugins/src/linker/Linker.test.tsx
new file mode 100644
index 0000000000..36fd550231
--- /dev/null
+++ b/packages/dashboard-core-plugins/src/linker/Linker.test.tsx
@@ -0,0 +1,162 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import {
+ OpenedPanelMap,
+ PanelComponent,
+ PanelManager,
+} from '@deephaven/dashboard';
+import GoldenLayout, { Config } from '@deephaven/golden-layout';
+import { TypeValue as FilterTypeValue } from '@deephaven/filters';
+import ToolType from './ToolType';
+import { Linker } from './Linker';
+import { Link, LinkPoint, LinkType } from './LinkerUtils';
+
+// Disable CSSTransition delays to make testing simpler
+jest.mock('react-transition-group', () => ({
+ // eslint-disable-next-line react/display-name, react/prop-types
+ Transition: ({ children, in: inProp }) =>
+ inProp !== false ? children : null,
+ // eslint-disable-next-line react/display-name, react/prop-types
+ CSSTransition: ({ children, in: inProp }) =>
+ inProp !== false ? children : null,
+}));
+
+function makeLayout() {
+ return new GoldenLayout({} as Config, undefined);
+}
+
+function makePanelManager(layout = makeLayout()) {
+ const PANEL_ID_A = 'PANEL_ID_A';
+ const PANEL_ID_B = 'PANEL_ID_B';
+ const openedMap: OpenedPanelMap = new Map([
+ [
+ PANEL_ID_A,
+ {
+ getCoordinateForColumn: jest.fn(() => {
+ const coordinate = [5, 5];
+ return coordinate; // make coordinates here
+ }),
+ } as PanelComponent,
+ ],
+ [
+ PANEL_ID_B,
+ {
+ getCoordinateForColumn: jest.fn(() => {
+ const coordinate = [50, 50];
+ return coordinate; // make coordinates here
+ }),
+ } as PanelComponent,
+ ],
+ ]);
+ return new PanelManager(layout, undefined, undefined, openedMap);
+}
+
+function makeLinkPoint(
+ panelId: string | string[],
+ columnName: string,
+ columnType: string | null,
+ panelComponent?: string | null
+): LinkPoint {
+ return { panelId, panelComponent, columnName, columnType };
+}
+
+function makeLink(
+ start: LinkPoint,
+ end: LinkPoint,
+ id: string,
+ type: LinkType,
+ isReversed?: boolean | undefined,
+ operator?: FilterTypeValue | undefined
+): Link {
+ return { start, end, id, isReversed, type, operator };
+}
+
+function mountLinker({
+ links = [] as Link[],
+ timeZone = 'TIMEZONE',
+ activeTool = ToolType.LINKER,
+ localDashboardId = 'TEST_ID',
+ layout = makeLayout(),
+ panelManager = makePanelManager(),
+ setActiveTool = jest.fn(),
+ setDashboardLinks = jest.fn(),
+ addDashboardLinks = jest.fn(),
+ deleteDashboardLinks = jest.fn(),
+ setDashboardIsolatedLinkerPanelId = jest.fn(),
+ setDashboardColumnSelectionValidator = jest.fn(),
+} = {}) {
+ return render(
+
+ );
+}
+
+it('closes Linker when escape key or Done button is pressed', async () => {
+ const setActiveTool = jest.fn();
+ mountLinker({ setActiveTool });
+ const dialog = screen.getByTestId('linker-toast-dialog');
+ const buttons = await screen.findAllByRole('button');
+ expect(buttons).toHaveLength(3);
+
+ const doneButton = screen.getByRole('button', { name: 'Done' });
+ fireEvent.click(doneButton);
+ expect(setActiveTool).toHaveBeenCalledWith(ToolType.DEFAULT);
+
+ fireEvent.keyDown(dialog, { key: 'Escape' });
+ expect(setActiveTool).toHaveBeenCalledWith(ToolType.DEFAULT);
+});
+
+describe('tests link operations', () => {
+ const deleteDashboardLinks = jest.fn();
+ const setDashboardLinks = jest.fn();
+ let linkPaths: HTMLElement[] = [];
+
+ beforeEach(async () => {
+ const links: Link[] = [];
+ for (let i = 0; i < 4; i += 1) {
+ const start = makeLinkPoint(
+ 'PANEL_ID_A',
+ `COLUMN_A`,
+ 'int',
+ 'PANEL_COMPONENT'
+ );
+ const end = makeLinkPoint(
+ 'PANEL_ID_B',
+ `COLUMN_B_${i}`,
+ 'long',
+ 'PANEL_COMPONENT'
+ );
+ const link = makeLink(start, end, `LINK_ID_${i}`, 'tableLink');
+ links.push(link);
+ }
+ mountLinker({ links, deleteDashboardLinks, setDashboardLinks });
+ linkPaths = screen.getAllByTestId('link-select');
+ expect(linkPaths).toHaveLength(4);
+ });
+
+ it('deletes correct link with alt+click', async () => {
+ expect(linkPaths).toHaveLength(4);
+ fireEvent.click(linkPaths[0], { altKey: true });
+ expect(deleteDashboardLinks).toHaveBeenCalledWith('TEST_ID', ['LINK_ID_0']);
+ });
+
+ it('deletes all links when Clear All is clicked', async () => {
+ const clearAllButton = screen.getByRole('button', { name: 'Clear All' });
+ fireEvent.click(clearAllButton);
+ expect(setDashboardLinks).toHaveBeenCalledWith('TEST_ID', []);
+ });
+});
diff --git a/packages/dashboard-core-plugins/src/linker/LinkerLink.test.tsx b/packages/dashboard-core-plugins/src/linker/LinkerLink.test.tsx
new file mode 100644
index 0000000000..d0f4555a8a
--- /dev/null
+++ b/packages/dashboard-core-plugins/src/linker/LinkerLink.test.tsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { Type as FilterType } from '@deephaven/filters';
+import LinkerLink from './LinkerLink';
+
+function makeLinkerLink({
+ x1 = 10,
+ x2 = 50,
+ y1 = 10,
+ y2 = 10,
+ isSelected = true,
+ startColumnType = 'int',
+ id = 'LINK_ID',
+ className = 'linker-link link-is-selected',
+ operator = FilterType.eq,
+ onClick = jest.fn(),
+ onDelete = jest.fn(),
+ onOperatorChanged = jest.fn(),
+} = {}) {
+ return render(
+
+ );
+}
+
+it('mounts and renders correct comparison operators for strings', async () => {
+ const onOperatorChanged = jest.fn();
+ const props = {
+ startColumnType: 'java.lang.String',
+ operator: FilterType.startsWith,
+ onOperatorChanged,
+ };
+ makeLinkerLink(props);
+
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ expect(dropdownAndDeleteButton[0]).toHaveTextContent('a*');
+
+ dropdownAndDeleteButton[0].click();
+ const dropdownMenu = await screen.findAllByRole('button');
+ expect(dropdownMenu).toHaveLength(8); // includes dropdown and delete button
+ expect(dropdownMenu[2]).toHaveTextContent('is exactly');
+ expect(dropdownMenu[3]).toHaveTextContent('is not exactly');
+ expect(dropdownMenu[4]).toHaveTextContent('contains');
+ expect(dropdownMenu[5]).toHaveTextContent('does not contain');
+ expect(dropdownMenu[6]).toHaveTextContent('starts with');
+ expect(dropdownMenu[7]).toHaveTextContent('ends with');
+
+ dropdownMenu[4].click();
+ expect(onOperatorChanged).toHaveBeenCalledWith(
+ 'LINK_ID',
+ FilterType.contains
+ );
+});
+
+it('renders correct symbol for endsWith', async () => {
+ makeLinkerLink({ operator: FilterType.endsWith });
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ expect(dropdownAndDeleteButton[0]).toHaveTextContent('*z');
+});
+
+it('mounts and renders correct comparison operators for numbers', async () => {
+ const props = {
+ x1: 10,
+ x2: 10,
+ y1: 30,
+ y2: 50,
+ startColumnType: 'long',
+ operator: FilterType.notEq,
+ };
+ makeLinkerLink(props);
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ expect(dropdownAndDeleteButton[0]).toHaveTextContent('!=');
+
+ dropdownAndDeleteButton[0].click();
+ const dropdownMenu = await screen.findAllByRole('button');
+ expect(dropdownMenu).toHaveLength(8); // includes dropdown and delete button
+ expect(dropdownMenu[2]).toHaveTextContent('is equal to');
+ expect(dropdownMenu[3]).toHaveTextContent('is not equal to');
+ expect(dropdownMenu[4]).toHaveTextContent('greater than');
+ expect(dropdownMenu[5]).toHaveTextContent('greater than or equal to');
+ expect(dropdownMenu[6]).toHaveTextContent('less than');
+ expect(dropdownMenu[7]).toHaveTextContent('less than or equal to');
+});
+
+it('mounts and renders correct comparison operators for date/time', async () => {
+ const props = {
+ x1: 10,
+ x2: 20,
+ y1: 50,
+ y2: 30,
+ startColumnType: 'io.deephaven.time.DateTime',
+ operator: FilterType.lessThan,
+ };
+ makeLinkerLink(props);
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ expect(dropdownAndDeleteButton[0]).toHaveTextContent('<');
+
+ dropdownAndDeleteButton[0].click();
+ const dropdownMenu = await screen.findAllByRole('button');
+ expect(dropdownMenu).toHaveLength(8); // includes dropdown and delete button
+ expect(dropdownMenu[2]).toHaveTextContent('date is');
+ expect(dropdownMenu[3]).toHaveTextContent('date is not');
+ expect(dropdownMenu[4]).toHaveTextContent('date is after');
+ expect(dropdownMenu[5]).toHaveTextContent('date is after or equal');
+ expect(dropdownMenu[6]).toHaveTextContent('date is before');
+ expect(dropdownMenu[7]).toHaveTextContent('date is before or equal');
+});
+
+it('mounts and renders correct comparison operators for booleans', async () => {
+ const props = {
+ x1: 10,
+ x2: 20,
+ y1: 30,
+ y2: 100,
+ startColumnType: 'boolean',
+ operator: FilterType.greaterThanOrEqualTo,
+ };
+ makeLinkerLink(props);
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ expect(dropdownAndDeleteButton[0]).toHaveTextContent('>=');
+
+ dropdownAndDeleteButton[0].click();
+ const dropdownMenu = await screen.findAllByRole('button');
+ expect(dropdownMenu).toHaveLength(4); // includes dropdown and delete button
+ expect(dropdownMenu[2]).toHaveTextContent('is equal to');
+ expect(dropdownMenu[3]).toHaveTextContent('is not equal to');
+});
+
+it('returns an empty label for invalid column type', async () => {
+ const startColumnType = 'INVALID_TYPE';
+ makeLinkerLink({ startColumnType });
+ expect(LinkerLink.getLabelForLinkFilter(startColumnType, FilterType.eq)).toBe(
+ ''
+ );
+});
+
+it('calls onClick when the link is clicked and onDelete on alt-click and button press', async () => {
+ const onClick = jest.fn();
+ const onDelete = jest.fn();
+ makeLinkerLink({ onClick, onDelete });
+
+ const linkPath = screen.getByTestId('link-select');
+ fireEvent.click(linkPath);
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(linkPath, { altKey: true });
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ const dropdownAndDeleteButton = await screen.findAllByRole('button');
+ dropdownAndDeleteButton[1].click();
+ expect(onDelete).toHaveBeenCalledTimes(2);
+});
diff --git a/packages/dashboard-core-plugins/src/linker/LinkerLink.tsx b/packages/dashboard-core-plugins/src/linker/LinkerLink.tsx
index cc924b98c8..6524271e6c 100644
--- a/packages/dashboard-core-plugins/src/linker/LinkerLink.tsx
+++ b/packages/dashboard-core-plugins/src/linker/LinkerLink.tsx
@@ -314,6 +314,7 @@ export class LinkerLink extends Component {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
clipPath={`url(#${clipPathId})`}
+ data-testid="link-select"
/>
diff --git a/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.test.tsx b/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.test.tsx
index 5ced793aee..9c8cd0c392 100644
--- a/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.test.tsx
+++ b/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.test.tsx
@@ -1,41 +1,103 @@
import React from 'react';
-import { render } from '@testing-library/react';
-import { PanelManager } from '@deephaven/dashboard';
-import GoldenLayout from '@deephaven/golden-layout';
+import { render, screen, fireEvent } from '@testing-library/react';
+import {
+ OpenedPanelMap,
+ PanelComponent,
+ PanelManager,
+} from '@deephaven/dashboard';
+import GoldenLayout, { Config } from '@deephaven/golden-layout';
import LinkerOverlayContent from './LinkerOverlayContent';
const LINKER_OVERLAY_MESSAGE = 'TEST_MESSAGE';
function makeLayout() {
- return new GoldenLayout({});
+ return new GoldenLayout({} as Config, undefined);
}
function makePanelManager(layout = makeLayout()) {
- return new PanelManager(layout);
+ const PANEL_ID_A = 'PANEL_ID_A';
+ const PANEL_ID_B = 'PANEL_ID_B';
+ const openedMap: OpenedPanelMap = new Map([
+ [
+ PANEL_ID_A,
+ {
+ getCoordinateForColumn: jest.fn(() => {
+ const coordinate = [5, 5];
+ return coordinate; // make coordinates here
+ }),
+ } as PanelComponent,
+ ],
+ [
+ PANEL_ID_B,
+ {
+ getCoordinateForColumn: jest.fn(() => {
+ const coordinate = [50, 50];
+ return coordinate; // make coordinates here
+ }),
+ } as PanelComponent,
+ ],
+ ]);
+ return new PanelManager(layout, undefined, undefined, openedMap);
}
function mountOverlay({
- links = [],
+ links = [] as Link[],
+ selectedIds = new Set(),
messageText = LINKER_OVERLAY_MESSAGE,
onLinkDeleted = jest.fn(),
onAllLinksDeleted = jest.fn(),
onCancel = jest.fn(),
onDone = jest.fn(),
+ onLinksUpdated = jest.fn(),
+ onLinkSelected = jest.fn(),
panelManager = makePanelManager(),
} = {}) {
return render(
);
}
-it('mounts and unmounts LinkerOverlay without crashing', () => {
- mountOverlay();
+it('calls appropriate functions on button and key presses', async () => {
+ const onLinkDeleted = jest.fn();
+ const onAllLinksDeleted = jest.fn();
+ const onCancel = jest.fn();
+ const onDone = jest.fn();
+ const selectedIds = new Set(['TEST_ID']);
+ mountOverlay({
+ onLinkDeleted,
+ onAllLinksDeleted,
+ onCancel,
+ onDone,
+ selectedIds,
+ });
+
+ const dialog = screen.getByTestId('linker-toast-dialog');
+ expect(dialog).toHaveTextContent(LINKER_OVERLAY_MESSAGE);
+ const buttons = await screen.findAllByRole('button');
+ expect(buttons).toHaveLength(3);
+
+ const clearAllButton = screen.getByRole('button', { name: 'Clear All' });
+ fireEvent.click(clearAllButton);
+ expect(onAllLinksDeleted).toHaveBeenCalled();
+
+ const doneButton = screen.getByRole('button', { name: 'Done' });
+ fireEvent.click(doneButton);
+ expect(onDone).toHaveBeenCalled();
+
+ fireEvent.keyDown(dialog, { key: 'Escape' });
+ expect(onCancel).toHaveBeenCalled();
+ fireEvent.keyDown(document, { key: 'Delete' });
+ fireEvent.keyDown(document, { key: 'Backspace' });
+ expect(onLinkDeleted).toHaveBeenCalledTimes(2);
});
diff --git a/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.tsx b/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.tsx
index 4867bf9a9a..ad9ae6694b 100644
--- a/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.tsx
+++ b/packages/dashboard-core-plugins/src/linker/LinkerOverlayContent.tsx
@@ -329,6 +329,7 @@ export class LinkerOverlayContent extends Component<
})}
ref={this.dialogRef}
style={{ bottom: dialog?.y, right: dialog?.x }}
+ data-testid="linker-toast-dialog"
>