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

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay