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" >