diff --git a/extension/src/test/fixtures/plotsDiff/template/smoothTemplatePlot.ts b/extension/src/test/fixtures/plotsDiff/template/smoothTemplatePlot.ts new file mode 100644 index 0000000000..3ae332dd20 --- /dev/null +++ b/extension/src/test/fixtures/plotsDiff/template/smoothTemplatePlot.ts @@ -0,0 +1,394 @@ +const smoothTemplatePlotContent = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', + data: { + values: [ + { + timestamp: '1651815999735', + step: '0', + acc: '0.2712', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816000510', + step: '1', + acc: '0.4104', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816001808', + step: '2', + acc: '0.5052', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816003335', + step: '3', + acc: '0.6678', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816005282', + step: '4', + acc: '0.5457', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816006730', + step: '5', + acc: '0.6654', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816008092', + step: '6', + acc: '0.6689', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816009423', + step: '7', + acc: '0.6841', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816010848', + step: '8', + acc: '0.7325', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816012290', + step: '9', + acc: '0.6935', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816013666', + step: '10', + acc: '0.7514', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816014874', + step: '11', + acc: '0.691', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816016290', + step: '12', + acc: '0.7712', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816017814', + step: '13', + acc: '0.7105', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651816018919', + step: '14', + acc: '0.7735', + dvc_data_version_info: { + revision: 'workspace', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'workspace' + }, + { + timestamp: '1651815999735', + step: '0', + acc: '0.2712', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816000510', + step: '1', + acc: '0.4104', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816001808', + step: '2', + acc: '0.5052', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816003335', + step: '3', + acc: '0.6678', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816005282', + step: '4', + acc: '0.5457', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816006730', + step: '5', + acc: '0.6654', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816008092', + step: '6', + acc: '0.6689', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816009423', + step: '7', + acc: '0.6841', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816010848', + step: '8', + acc: '0.7325', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816012290', + step: '9', + acc: '0.6935', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816013666', + step: '10', + acc: '0.7514', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816014874', + step: '11', + acc: '0.691', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816016290', + step: '12', + acc: '0.7712', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816017814', + step: '13', + acc: '0.7105', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + }, + { + timestamp: '1651816018919', + step: '14', + acc: '0.7735', + dvc_data_version_info: { + revision: '208f073', + filename: 'training_metrics/scalars/acc.tsv' + }, + rev: 'smooth-plots' + } + ] + }, + title: 'training_metrics/scalars/acc.tsv', + width: 300, + height: 300, + params: [ + { + name: 'smooth', + value: 0.2, + bind: { input: 'range', min: 0.001, max: 1, step: 0.01 } + } + ], + transform: [ + { + loess: 'acc', + on: 'step', + groupby: ['rev'], + bandwidth: { signal: 'smooth' } + } + ], + layer: [ + { + encoding: { + x: { field: 'step', type: 'quantitative', title: 'step' }, + y: { + field: 'acc', + type: 'quantitative', + title: 'acc', + scale: { zero: false } + }, + color: { field: 'rev', type: 'nominal' } + }, + layer: [ + { mark: 'line' }, + { + selection: { + label: { + type: 'single', + nearest: true, + on: 'mouseover', + encodings: ['x'], + empty: 'none', + clear: 'mouseout' + } + }, + mark: 'point', + encoding: { + opacity: { + condition: { selection: 'label', value: 1 }, + value: 0 + } + } + } + ] + }, + { + transform: [{ filter: { selection: 'label' } }], + layer: [ + { + mark: { type: 'rule', color: 'gray' }, + encoding: { x: { field: 'step', type: 'quantitative' } } + }, + { + encoding: { + text: { type: 'quantitative', field: 'acc' }, + x: { field: 'step', type: 'quantitative' }, + y: { field: 'acc', type: 'quantitative' } + }, + layer: [ + { + mark: { type: 'text', align: 'left', dx: 5, dy: -5 }, + encoding: { color: { type: 'nominal', field: 'rev' } } + } + ] + } + ] + } + ], + encoding: { + color: { + legend: { disable: true }, + scale: { + domain: ['workspace', 'smooth-plots'], + range: ['#945dd6', '#13adc7'] + } + } + } +} + +export default smoothTemplatePlotContent diff --git a/webview/src/plots/components/App.test.tsx b/webview/src/plots/components/App.test.tsx index 433071bd3e..b27c85d105 100644 --- a/webview/src/plots/components/App.test.tsx +++ b/webview/src/plots/components/App.test.tsx @@ -11,6 +11,7 @@ import { fireEvent, render, screen, + waitFor, within } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' @@ -18,6 +19,7 @@ import comparisonTableFixture from 'dvc/src/test/fixtures/plotsDiff/comparison' import checkpointPlotsFixture from 'dvc/src/test/fixtures/expShow/checkpointPlots' import plotsRevisionsFixture from 'dvc/src/test/fixtures/plotsDiff/revisions' import templatePlotsFixture from 'dvc/src/test/fixtures/plotsDiff/template/webview' +import smoothTemplatePlotContent from 'dvc/src/test/fixtures/plotsDiff/template/smoothTemplatePlot' import manyTemplatePlots from 'dvc/src/test/fixtures/plotsDiff/template/virtualization' import { DEFAULT_SECTION_COLLAPSED, @@ -125,6 +127,45 @@ describe('App', () => { const getCheckpointSizePickerButton = () => getCheckpointMenuItem(1) + const changeSize = async ( + size: string, + buttonPosition: number, + wrapper: HTMLElement + ) => { + const sizePickerButton = + within(wrapper).getAllByTestId('icon-menu-item')[buttonPosition] + fireEvent.mouseEnter(sizePickerButton) + fireEvent.click(sizePickerButton) + + const sizeButton = await within(wrapper).findByText(size) + + fireEvent.click(sizeButton) + await screen.findAllByTestId('plots-wrapper') + fireEvent.click(sizePickerButton) + await screen.findAllByTestId('plots-wrapper') + } + + const renderAppAndChangeSize = async ( + data: PlotsData, + size: string, + section: Section + ) => { + renderAppWithOptionalData({ + ...data, + sectionCollapsed: DEFAULT_SECTION_COLLAPSED + }) + + const sectionButtonPosition = { + [Section.CHECKPOINT_PLOTS]: 1, + [Section.TEMPLATE_PLOTS]: 0, + [Section.COMPARISON_TABLE]: 0 + } + const wrappers = await screen.findAllByTestId('plots-container') + const wrapper = wrappers[sectionPosition[section]] + + await changeSize(size, sectionButtonPosition[section], wrapper) + } + beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, @@ -1124,45 +1165,6 @@ describe('App', () => { }) describe('Virtualization', () => { - const changeSize = async ( - size: string, - buttonPosition: number, - wrapper: HTMLElement - ) => { - const sizePickerButton = - within(wrapper).getAllByTestId('icon-menu-item')[buttonPosition] - fireEvent.mouseEnter(sizePickerButton) - fireEvent.click(sizePickerButton) - - const sizeButton = await within(wrapper).findByText(size) - - fireEvent.click(sizeButton) - await screen.findAllByTestId('plots-wrapper') - fireEvent.click(sizePickerButton) - await screen.findAllByTestId('plots-wrapper') - } - - const renderAppAndChangeSize = async ( - data: PlotsData, - size: string, - section: Section - ) => { - renderAppWithOptionalData({ - ...data, - sectionCollapsed: DEFAULT_SECTION_COLLAPSED - }) - - const sectionButtonPosition = { - [Section.CHECKPOINT_PLOTS]: 1, - [Section.TEMPLATE_PLOTS]: 0, - [Section.COMPARISON_TABLE]: 0 - } - const wrappers = await screen.findAllByTestId('plots-container') - const wrapper = wrappers[sectionPosition[section]] - - await changeSize(size, sectionButtonPosition[section], wrapper) - } - const createCheckpointPlots = (nbOfPlots: number) => { const plots = [] for (let i = 0; i < nbOfPlots; i++) { @@ -1660,4 +1662,95 @@ describe('App', () => { ]) }) }) + + describe('Vega panels', () => { + const smoothId = join('template', 'smooth.tsv') + const withVegaPanels = { + ...templatePlotsFixture, + plots: [ + { + entries: [ + ...templatePlotsFixture.plots[0].entries, + { + ...templatePlotsFixture.plots[0].entries[0], + content: { ...smoothTemplatePlotContent }, + id: smoothId + } + ], + group: TemplatePlotGroup.SINGLE_VIEW + } as TemplatePlotSection + ] + } + + const waitForVega = async (smoothPlot: HTMLElement) => { + await waitFor(() => + // eslint-disable-next-line testing-library/no-node-access + expect(smoothPlot.querySelectorAll('.marks')[0]).toBeInTheDocument() + ) + } + + const getPanel = (smoothPlot: HTMLElement) => + // eslint-disable-next-line testing-library/no-node-access + smoothPlot.querySelector('.vega-bindings') + + it('should disable a template plot from drag and drop when hovering a vega panel', async () => { + await renderAppAndChangeSize( + { template: withVegaPanels }, + 'Small', + Section.TEMPLATE_PLOTS + ) + + const smoothPlot = screen.getByTestId(`plot_${smoothId}`) + + await waitForVega(smoothPlot) + + const panel = getPanel(smoothPlot) + expect(panel).toBeInTheDocument() + + expect(smoothPlot.draggable).toBe(true) + + panel && fireEvent.mouseEnter(panel) + + expect(smoothPlot.draggable).toBe(false) + }) + + it('should re-enable a template plot for drag and drop when the mouse leaves a vega panel', async () => { + await renderAppAndChangeSize( + { template: withVegaPanels }, + 'Small', + Section.TEMPLATE_PLOTS + ) + + const smoothPlot = screen.getByTestId(`plot_${smoothId}`) + + await waitForVega(smoothPlot) + + const panel = getPanel(smoothPlot) + expect(panel).toBeInTheDocument() + + panel && fireEvent.mouseEnter(panel) + panel && fireEvent.mouseLeave(panel) + expect(smoothPlot.draggable).toBe(true) + }) + + it('should disable zooming the template plot when clicking inside the vega panel', async () => { + await renderAppAndChangeSize( + { template: withVegaPanels }, + 'Small', + Section.TEMPLATE_PLOTS + ) + + const smoothPlot = screen.getByTestId(`plot_${smoothId}`) + + await waitForVega(smoothPlot) + + const panel = getPanel(smoothPlot) || smoothPlot + expect(panel).toBeInTheDocument() + + const clickEvent = createEvent.click(panel) + clickEvent.stopPropagation = jest.fn() + fireEvent(panel, clickEvent) + expect(clickEvent.stopPropagation).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/webview/src/plots/components/styles.module.scss b/webview/src/plots/components/styles.module.scss index 4238db3f0b..a63d561c10 100644 --- a/webview/src/plots/components/styles.module.scss +++ b/webview/src/plots/components/styles.module.scss @@ -42,6 +42,25 @@ $gap: 20px; width: 100%; } +:global(.vega-bindings):not(:empty) { + position: absolute; + bottom: 0; + width: 100%; + background-color: $fg-color; + padding: 10px; + display: none; + + label { + display: flex; + align-items: center; + justify-content: center; + } + + input { + accent-color: $accent-color; + } +} + :global(.vega-embed) { width: 100%; height: 100%; @@ -50,6 +69,12 @@ $gap: 20px; align-items: center; justify-content: center; + &:hover { + :global(.vega-bindings) { + display: block; + } + } + svg { overflow: visible; } diff --git a/webview/src/plots/components/templatePlots/TemplatePlotsGrid.tsx b/webview/src/plots/components/templatePlots/TemplatePlotsGrid.tsx index 18f8a4fe32..371de3a1b1 100644 --- a/webview/src/plots/components/templatePlots/TemplatePlotsGrid.tsx +++ b/webview/src/plots/components/templatePlots/TemplatePlotsGrid.tsx @@ -43,11 +43,47 @@ export const TemplatePlotsGrid: React.FC = ({ parentDraggedOver }) => { const [order, setOrder] = useState([]) + const [disabledDrag, setDisabledDrag] = useState('') useEffect(() => { setOrder(entries.map(({ id }) => id)) }, [entries]) + useEffect(() => { + const panels = document.querySelectorAll('.vega-bindings') + const addDisabled = (e: Event) => { + setDisabledDrag( + (e.currentTarget as HTMLFormElement).parentElement?.parentElement + ?.parentElement?.id || '' + ) + } + + const removeDisabled = () => { + setDisabledDrag('') + } + + const disableClick = (e: Event) => { + e.stopPropagation() + } + + for (const panel of Object.values(panels)) { + panel.removeEventListener('mouseenter', addDisabled) + panel.removeEventListener('mouseleave', removeDisabled) + panel.removeEventListener('click', disableClick) + panel.addEventListener('mouseenter', addDisabled) + panel.addEventListener('mouseleave', removeDisabled) + panel.addEventListener('click', disableClick) + } + + return () => { + for (const panel of Object.values(panels)) { + panel.removeEventListener('mouseenter', addDisabled) + panel.removeEventListener('mouseleave', removeDisabled) + panel.removeEventListener('click', disableClick) + } + } + }) + const setEntriesOrder = (order: string[]) => { setOrder(order) @@ -104,6 +140,7 @@ export const TemplatePlotsGrid: React.FC = ({ : undefined } parentDraggedOver={parentDraggedOver} + disabledDropIds={[disabledDrag]} /> ) } diff --git a/webview/src/stories/Plots.stories.tsx b/webview/src/stories/Plots.stories.tsx index 0203084872..0436fd4c95 100644 --- a/webview/src/stories/Plots.stories.tsx +++ b/webview/src/stories/Plots.stories.tsx @@ -6,7 +6,9 @@ import { userEvent, within } from '@storybook/testing-library' import { PlotsData, DEFAULT_SECTION_COLLAPSED, - PlotSize + PlotSize, + TemplatePlotGroup, + TemplatePlotSection } from 'dvc/src/plots/webview/contract' import { MessageToWebviewType } from 'dvc/src/webview/contract' import checkpointPlotsFixture, { @@ -16,6 +18,7 @@ import templatePlotsFixture from 'dvc/src/test/fixtures/plotsDiff/template' import manyTemplatePlots from 'dvc/src/test/fixtures/plotsDiff/template/virtualization' import comparisonPlotsFixture from 'dvc/src/test/fixtures/plotsDiff/comparison' import plotsRevisionsFixture from 'dvc/src/test/fixtures/plotsDiff/revisions' +import smoothTemplatePlotContent from 'dvc/src/test/fixtures/plotsDiff/template/smoothTemplatePlot' import { chromaticParameters } from './util' import { Plots } from '../plots/components/Plots' @@ -210,3 +213,24 @@ MultiviewZoomedInPlot.play = async ({ canvasElement }) => { userEvent.click(plotButton) } + +export const SmoothTemplate = Template.bind({}) +SmoothTemplate.args = { + data: { + template: { + ...templatePlotsFixture, + plots: [ + { + entries: [ + ...templatePlotsFixture.plots[0].entries.map(plot => ({ + ...plot, + content: { ...smoothTemplatePlotContent } + })) + ], + group: TemplatePlotGroup.SINGLE_VIEW + } as unknown as TemplatePlotSection + ] + } + } +} +SmoothTemplate.parameters = chromaticParameters