DEV Community

Samuel Kendrick
Samuel Kendrick

Posted on

Rubber Duck Driven Development: A general purpose draggable menu.

Song I listened to while starting: https://www.youtube.com/watch?v=N2yg63_vnqg&ab_channel=TheChemicalBrothers-Topic

Hello Rubber Ducky,

Today I'm making a general purpose draggable menu which will be used in many different applications. A concrete example is to emulate something like this big panel:

Image description

Or this little panel:

Image description

I was given some specifications in a GitHub issue: https://github.com/VEuPathDB/web-components/issues/420.

I read them and tried to come up with some behavior specifications to better define what this component is and what it does. Here's what I came up with:

  • Panels can be opened and closed.
  • Panels live somewhere initially.
  • Users can change where panels live using their cursor/finger.
    • This, of course, implies that users can identify the container's handle.
  • We can put anything into a draggable panel.

Some public-facing API specs for the draggable container:

  • It should have a defaultPosition.
  • It should emit a position via an onStop. That's how I'm interpreting "an externally state-controlled position."
  • It should surface an onClose.
  • It should also have a setOpen.
  • It "should be standardised with the EDA's GlobalFiltersDialog (which has a title and close icon, but no handle)."
    • Of note: I'm assuming the thing being standardized is the appearance.

I added some testing tools in: https://github.com/VEuPathDB/CoreUI/pull/138. With these I can take a stab at converting a specification in to functional code.

describe("Draggable Panel", () => {
  test("panels live somewhere initially.", () => {});
  test("dragging a panel changes where it lives.", () => {});
  test("when there are two draggables stacked on each other, only the panel the user drags will move.", () => {});
  test("users can close draggable panels.", () => {});
  test("users can open draggable panels.", () => {});
});
Enter fullscreen mode Exit fullscreen mode

In the end

Here are the tests I came up with:

import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { DraggablePanel, DraggablePanelCoordinatePair } from "./DraggablePanel";

describe("Draggable Panels", () => {
  test("dragging a panel changes where it lives.", () => {
    const defaultPosition: DraggablePanelCoordinatePair = { x: 0, y: 0 };
    const panelTitleForAccessibilityOnly = "Study Filters Panel";
    const handleOnDragComplete = jest.fn();
    render(
      <DraggablePanel
        defaultPosition={defaultPosition}
        isOpen
        onDragComplete={handleOnDragComplete}
        onPanelDismiss={() => {}}
        panelTitle={panelTitleForAccessibilityOnly}
        showPanelTitle
      >
        <p>Panel contents</p>
      </DraggablePanel>
    );
    const panelDragHandle = screen.getByText(
      `Close ${panelTitleForAccessibilityOnly}`
    );

    const destinationCoordinates = { clientX: 73, clientY: 22 };

    drag(panelDragHandle, destinationCoordinates);

    /**
     * I really don't like assert on implementation details. If we change React dragging librbaries,
     * this assertion could break and raise a false positive. That said, jsdom doesn't render layouts
     * like a legit browser so we're left with this and data-testids. The data-testid is nice because
     * at least we're in control of that so we can make sure that doesn't change if we swap dragging
     * providers. See conversations like: https://softwareengineering.stackexchange.com/questions/234024/unit-testing-behaviours-without-coupling-to-implementation-details
     */
    const panelFromDataTestId = screen.getByTestId(
      `${panelTitleForAccessibilityOnly} dragged`
    );
    expect(panelFromDataTestId.style.transform).toEqual(
      `translate(${destinationCoordinates.clientX}px,${destinationCoordinates.clientY}px)`
    );

    expect(panelFromDataTestId).toBeTruthy();
    expect(handleOnDragComplete).toHaveBeenCalled();
  });

  test("you can open and close panels", async () => {
    const defaultPosition = { x: 50, y: 50 };

    function ToggleButtonAndDraggablePanel() {
      const [panelIsOpen, setPanelIsOpen] = useState(true);
      return (
        <>
          <button onClick={() => setPanelIsOpen((isOpen) => !isOpen)}>
            Toggle Filters Panel
          </button>
          <DraggablePanel
            defaultPosition={defaultPosition}
            isOpen={panelIsOpen}
            panelTitle="My Filters"
            onDragComplete={() => {}}
            onPanelDismiss={() => setPanelIsOpen(false)}
            showPanelTitle
          >
            <p>I might be here or I might be gone</p>
          </DraggablePanel>
        </>
      );
    }

    render(
      <>
        <ToggleButtonAndDraggablePanel />
        <DraggablePanel
          defaultPosition={defaultPosition}
          isOpen
          panelTitle="My Extra Ordinary Data"
          onDragComplete={() => {}}
          onPanelDismiss={() => {}}
          showPanelTitle
        >
          <p>I will be with you forever.</p>
        </DraggablePanel>
      </>
    );

    expect(
      screen.getByText("I might be here or I might be gone")
    ).toBeVisible();

    const closePanel = screen.getByText("Close My Filters");
    fireEvent.click(closePanel);

    expect(
      screen.queryByText("I might be here or I might be gone")
    ).not.toBeVisible();
    expect(screen.queryByText("I will be with you forever.")).toBeVisible();

    fireEvent.click(screen.getByText("Toggle Filters Panel"));
    expect(
      screen.getByText("I might be here or I might be gone")
    ).toBeVisible();
  });
});

/**
 * So we're pretty limited as regards js-dom and dragging. Here's what I would like to do:
 * 1. Simulate dragging events on the draggable element.
 * 2. Find the element, getBoundingClientRect for the element
 * 3. Assert that the coordinates moved predictably.
 *
 * Here's the reality: jsdom doesn't do any rendering, so getBoundingClientRect() always
 * returns 0,0,0,0. That won't change (even foreseeable long-term).
 * You can try to mock the function to emulate the results you'd expect.
 * https://github.com/jsdom/jsdom/issues/1590#issuecomment-243228840
 *
 * @param element
 * @param destinationCoordinates
 */
function drag(
  element: HTMLElement,
  destinationCoordinates: { clientX: number; clientY: number }
): void {
  fireEvent.mouseDown(element);
  fireEvent.mouseMove(element, destinationCoordinates);
  fireEvent.mouseUp(element);
}
Enter fullscreen mode Exit fullscreen mode

https://github.com/VEuPathDB/CoreUI/pull/140/files

Top comments (0)