Skip to content

Commit

Permalink
refactor: replace intersection observer in board (#4291)
Browse files Browse the repository at this point in the history
Co-authored-by: Manuel Brandstetter <[email protected]>
  • Loading branch information
Schwehn42 and brandstetterm authored Jan 13, 2025
1 parent d4666e5 commit cdc82ea
Show file tree
Hide file tree
Showing 3 changed files with 52 additions and 208 deletions.
117 changes: 15 additions & 102 deletions src/components/Board/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {MenuBars} from "components/MenuBars";
import {InfoBar} from "components/Infobar";
import {BoardHeader} from "components/BoardHeader";
import {HotkeyAnchor} from "components/HotkeyAnchor";
import "./Board.scss";
import {useDndMonitor} from "@dnd-kit/core";
import classNames from "classnames";
import {useStripeOffset} from "utils/hooks/useStripeOffset";
import {Toast} from "utils/Toast";
import {useTranslation} from "react-i18next";
import {useIsTouchingSides} from "utils/hooks/useIsTouchingSides";
import "./Board.scss";

export interface BoardProps {
children: React.ReactElement<ColumnProps> | React.ReactElement<ColumnProps>[];
Expand All @@ -19,30 +20,8 @@ export interface BoardProps {
locked: boolean;
}

export interface BoardState {
showNextButton: boolean;
showPreviousButton: boolean;
}

export interface ColumnState {
firstVisibleColumnIndex: number;
lastVisibleColumnIndex: number;
}

export const BoardComponent = ({children, currentUserIsModerator, moderating, locked}: BoardProps) => {
const {t} = useTranslation();
const [state, setState] = useState<BoardState & ColumnState>({
firstVisibleColumnIndex: 0,
lastVisibleColumnIndex: React.Children.count(children),
showNextButton: false,
showPreviousButton: false,
});

const [columnState, setColumnState] = useState<ColumnState>({
firstVisibleColumnIndex: 0,
lastVisibleColumnIndex: React.Children.count(children),
});

const [dragActive, setDragActive] = useState(false);
useDndMonitor({
onDragStart() {
Expand All @@ -57,88 +36,22 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo
});

const boardRef = useRef<HTMLDivElement>(null);
const columnVisibilityStatesRef = useRef<boolean[]>([]);

const columnsCount = React.Children.count(children);

// stripe offset for spacer divs
const leftSpacerOffset = useStripeOffset<HTMLDivElement>({gradientLength: 40, gradientAngle: 45});
const rightSpacerOffset = useStripeOffset<HTMLDivElement>({gradientLength: 40, gradientAngle: 45});

const {isTouchingLeftSide, isTouchingRightSide} = useIsTouchingSides(boardRef);

useEffect(() => {
leftSpacerOffset.updateOffset();
rightSpacerOffset.updateOffset();

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);

useEffect(() => {
const board = boardRef.current;

if (board) {
// initialize column visibility states
columnVisibilityStatesRef.current = new Array(React.Children.count(children));
const columnVisibilityStates = columnVisibilityStatesRef.current;
columnVisibilityStates.fill(false);

// initialize intersection observer
const observerOptions = {
root: board,
rootMargin: "0px",
threshold: 1.0,
};
const observerCallback: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
const index = Array.prototype.indexOf.call(board.children, entry.target) - 1;
columnVisibilityStates[index] = entry.isIntersecting;
});

const firstVisibleColumnIndex = columnVisibilityStates.findIndex((value) => value);
const lastVisibleColumnIndex = columnVisibilityStates.lastIndexOf(true);

setColumnState({
firstVisibleColumnIndex,
lastVisibleColumnIndex,
});
};
const observer = new IntersectionObserver(observerCallback, observerOptions);

// observe children
const domChildren = board.children;
for (let i = 1; i < domChildren.length - 1; i += 1) {
observer.observe(domChildren[i]);
}

// return callback handler that will disconnect the observer on unmount
return () => {
observer.disconnect();
};
}
return undefined;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [children]);

useEffect(() => {
let firstVisibleColumnIndex;
let lastVisibleColumnIndex;

if (columnState.firstVisibleColumnIndex === -1 && columnState.lastVisibleColumnIndex === -1) {
firstVisibleColumnIndex = state.firstVisibleColumnIndex;
lastVisibleColumnIndex = state.firstVisibleColumnIndex - 1;
} else {
firstVisibleColumnIndex = columnState.firstVisibleColumnIndex;
lastVisibleColumnIndex = columnState.lastVisibleColumnIndex;
}

setState({
firstVisibleColumnIndex,
lastVisibleColumnIndex,
showNextButton: lastVisibleColumnIndex < columnsCount - 1,
showPreviousButton: firstVisibleColumnIndex > 0,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnState]);

useEffect(() => {
if (locked) {
Toast.info({title: t("Toast.lockedBoard"), autoClose: 10_000});
Expand All @@ -163,26 +76,26 @@ export const BoardComponent = ({children, currentUserIsModerator, moderating, lo
);
}

const {firstVisibleColumnIndex, lastVisibleColumnIndex} = state;
const columnColors = React.Children.map(children, (child) => child.props.color);

const previousColumnIndex = firstVisibleColumnIndex > 0 ? firstVisibleColumnIndex - 1 : columnsCount - 1;
const nextColumnIndex = lastVisibleColumnIndex === columnsCount - 1 ? 0 : firstVisibleColumnIndex + 1;

const handlePreviousClick = () => {
boardRef.current!.children[previousColumnIndex + 1].scrollIntoView({inline: "start", behavior: "smooth"});
};

const handleNextClick = () => {
boardRef.current!.children[nextColumnIndex + 1].scrollIntoView({inline: "start", behavior: "smooth"});
const scrollBoard = (direction: "left" | "right") => {
const boardWidth = boardRef.current?.scrollWidth ?? 0;
const columnWidth = boardWidth / columnsCount;
const scrollValue = columnWidth * (direction === "left" ? -1 : 1);
boardRef.current?.scrollBy({left: scrollValue, behavior: "smooth"});
};

return (
<>
<style>{`.board { --board__columns: ${columnsCount} }`}</style>
<BoardHeader currentUserIsModerator={currentUserIsModerator} />
<InfoBar />
<MenuBars showPreviousColumn={state.showPreviousButton} showNextColumn={state.showNextButton} onPreviousColumn={handlePreviousClick} onNextColumn={handleNextClick} />
<MenuBars
showPreviousColumn={!isTouchingLeftSide}
showNextColumn={!isTouchingRightSide}
onPreviousColumn={() => scrollBoard("left")}
onNextColumn={() => scrollBoard("right")}
/>
<HotkeyAnchor />
<main className={classNames("board", dragActive && "board--dragging")} ref={boardRef}>
<div
Expand Down
109 changes: 3 additions & 106 deletions src/components/Board/__tests__/Board.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {act, fireEvent} from "@testing-library/react";
import {Column} from "components/Column";
import {Color} from "constants/colors";
import {Provider} from "react-redux";
Expand Down Expand Up @@ -26,16 +25,6 @@ const createBoardWithColumns = (...colors: Color[]) => {
};

describe("basic", () => {
beforeEach(() => {
window.IntersectionObserver = jest.fn(
() =>
({
observe: jest.fn(),
disconnect: jest.fn(),
}) as unknown as IntersectionObserver
);
});

test("show empty board", () => {
const {container} = render(createBoardWithColumns());
expect(container.firstChild).toHaveClass("board--empty");
Expand Down Expand Up @@ -89,103 +78,11 @@ describe("basic", () => {
});

test("side-panels have correct accent color with single column", () => {
const {container} = render(createBoardWithColumns("lean-lilac"));
const {container} = render(createBoardWithColumns("value-violet"));
const board = container.querySelector(".board");
expect(board?.childNodes[1]).toHaveClass("accent-color__lean-lilac");
expect(board?.lastChild).toHaveClass("accent-color__lean-lilac");
});
});
});
});

describe("navigation", () => {
beforeEach(() => {
window.IntersectionObserver = jest.fn(
() =>
({
observe: jest.fn(),
disconnect: jest.fn(),
}) as unknown as IntersectionObserver
);

const root = global.document.createElement("div");
root.setAttribute("id", "root");
global.document.querySelector("body")!.appendChild(root);
});

let intersectionObserver: IntersectionObserver;

beforeEach(() => {
intersectionObserver = {
observe: jest.fn(),
disconnect: jest.fn(),
} as unknown as IntersectionObserver;
window.IntersectionObserver = jest.fn(() => intersectionObserver);
});

test("intersection observer is registered on mount", () => {
render(createBoardWithColumns("lean-lilac", "backlog-blue"));
expect(window.IntersectionObserver).toHaveBeenCalled();
expect(intersectionObserver.observe).toHaveBeenCalledTimes(2);
});

test("intersection observer is disconnected on unmount", () => {
render(createBoardWithColumns("planning-pink")).unmount();
expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1);
});

test("intersection observer is re-initialized on change of children", () => {
const {rerender} = render(createBoardWithColumns("planning-pink"));

expect(window.IntersectionObserver).toHaveBeenCalledTimes(1);
expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(0);

rerender(createBoardWithColumns("planning-pink", "backlog-blue"));

expect(window.IntersectionObserver).toHaveBeenCalledTimes(2);
expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1);
});

describe("buttons visibility and functionality", () => {
let container: HTMLElement;

beforeEach(() => {
container = render(createBoardWithColumns("planning-pink", "backlog-blue", "poker-purple")).container;
});

const showColumns = (first: boolean, second: boolean, third: boolean) => {
const columns = container.querySelectorAll(".column");
act(() => {
const firstMethodCall = 0;
const firstMethodParameter = 0;

const intersectionObserverCallback = (window.IntersectionObserver as unknown as IntersectionObserver).mock.calls[firstMethodCall][firstMethodParameter];
intersectionObserverCallback([
{isIntersecting: first, target: columns[0]},
{isIntersecting: second, target: columns[1]},
{isIntersecting: third, target: columns[2]},
]);
expect(board?.childNodes[1]).toHaveClass("accent-color__value-violet");
expect(board?.lastChild).toHaveClass("accent-color__value-violet");
});
return columns;
};

test("correct scroll of previous button", () => {
const columns = showColumns(false, true, false);
const scrollIntoView = jest.fn();
columns[0].scrollIntoView = scrollIntoView;
fireEvent.click(container.querySelectorAll(".menu-bars__navigation")[0] as HTMLElement);

expect(scrollIntoView).toHaveBeenCalled();
});

test("correct scroll of next button", () => {
const columns = showColumns(false, true, false);

const scrollIntoView = jest.fn();
columns[2].scrollIntoView = scrollIntoView;
fireEvent.click(container.querySelectorAll(".menu-bars__navigation")[1] as HTMLElement);

expect(scrollIntoView).toHaveBeenCalled();
});
});
});
34 changes: 34 additions & 0 deletions src/utils/hooks/useIsTouchingSides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {useEffect, useState, RefObject, useCallback} from "react";
import {useSize} from "./useSize";
import {useIsScrolling} from "./useIsScrolling";

/**
* returns if the container is scrolled to the very left or very right of the viewport.
* @param ref the container ref which is checked
*/
export const useIsTouchingSides = (ref: RefObject<HTMLDivElement>) => {
// margin of error, because different browsers treat the scroll width differently
const EPSILON = 1;

const [isTouchingLeftSide, setIsTouchingLeftSide] = useState<boolean>(false);
const [isTouchingRightSide, setIsTouchingRightSide] = useState<boolean>(false);
const size = useSize(ref);
const isScrolling = useIsScrolling(ref, 30);

const checkTouchingSides = useCallback(() => {
if (!ref.current || !size) return;

const {scrollLeft: currentScrollLeft, scrollWidth: currentScrollWidth, clientWidth: currentClientWidth} = ref.current;
const touchingLeft = currentScrollLeft <= EPSILON;
const touchingRight = currentScrollLeft + currentClientWidth >= currentScrollWidth - EPSILON;

setIsTouchingLeftSide(touchingLeft);
setIsTouchingRightSide(touchingRight);
}, [ref, size]);

useEffect(() => {
checkTouchingSides();
}, [size, isScrolling, checkTouchingSides]);

return {isTouchingLeftSide, isTouchingRightSide};
};

0 comments on commit cdc82ea

Please sign in to comment.