Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/__tests__/setup-jsdom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import $ from "jquery";

//@ts-expect-error add to global
global["$"] = $;
//@ts-expect-error add to global
global["jQuery"] = $;
4 changes: 2 additions & 2 deletions frontend/__tests__/setup-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { vi } from "vitest";
import $ from "jquery";
import { ElementWithUtils } from "../src/ts/utils/dom";

//@ts-expect-error add to globl
//@ts-expect-error add to global
global["$"] = $;
//@ts-expect-error add to globl
//@ts-expect-error add to global
global["jQuery"] = $;

vi.mock("../src/ts/constants/env-config", () => ({
Expand Down
2 changes: 1 addition & 1 deletion frontend/__tests__/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"noEmit": true
},
"files": ["vitest.d.ts"],
"include": ["./**/*.spec.ts", "./setup-tests.ts"]
"include": ["./**/*.spec.ts", "./**/*.jsdom-spec.ts", "./setup-tests.ts"]
}
171 changes: 171 additions & 0 deletions frontend/__tests__/utils/dom.jsdom-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/dom";
import { userEvent } from "@testing-library/user-event";
import { ElementWithUtils, qsr } from "../../src/ts/utils/dom";

describe("dom", () => {
describe("ElementWithUtils", () => {
describe("onChild", () => {
const handler = vi.fn();

function registerOnChild(
event: string,
selector: string,
options?: {
parent?: ElementWithUtils;
},
): void {
const parent = options?.parent ?? qsr("#parent");
parent?.onChild(event, selector, (e) =>
handler({
target: e.target,
childTarget: e.childTarget,
//@ts-expect-error will be added later, check TODO on the ChildEvent
currentTarget: e.currentTarget,
}),
);
}

beforeEach(() => {
handler.mockReset();

document.body.innerHTML = "";
const root = document.createElement("div");

root.innerHTML = `
<div id="parent" data-testid="parent">
<section id="decoy">
<div id="mid1" data-testid="mid1" class="middle">
<div id="inner1" class="inner">test</div>
<div id="inner2" data-testid="inner2" class="inner">
test
<button id="button" data-testid="button">
click me
<i id="icon" data-testid="icon">test</i>
</button>
</div>
</div>
<div id="mid2" class="middle">
<div id="inner3" class="inner">test</div>
<div id="inner4" class="inner">test</div>
</div>
</section>
</div>
`;
document.body.appendChild(root);
});

it("should not fire when parent element is clicked", async () => {
//GIVEN
registerOnChild("click", "div");

//WHEN
await userEvent.click(screen.getByTestId("parent"));

//THEN
expect(handler).not.toHaveBeenCalled();
});

it("should not fire when selector doesnt match", async () => {
//GIVEN
const buttonEl = qsr("#button");
registerOnChild("click", "div", { parent: buttonEl });

//WHEN
await userEvent.click(screen.getByTestId("icon"));

//THEN
expect(handler).not.toHaveBeenCalled();
});

it("should fire when selector is clicked", async () => {
//GIVEN
registerOnChild("click", "div");

//WHEN
const clickTarget = screen.getByTestId("mid1");
await userEvent.click(clickTarget);

//THEN
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: clickTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});

it("should fire when child of selector is clicked", async () => {
//GIVEN
registerOnChild("click", "div.middle");

//WHEN
const selectorTarget = screen.getByTestId("mid1");
const clickTarget = screen.getByTestId("button");
await userEvent.click(clickTarget);

//THEN
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: selectorTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});

it("should fire on each element matching the selector from the child up to the parent", async () => {
//GIVEN
registerOnChild("click", "div.middle, div.inner");

//WHEN
let clickTarget = screen.getByTestId("button");
await userEvent.click(clickTarget);

//THEN

//This is the same behavior as jQuery `.on` with selector.
//The handler will be called two times,
//It does NOT call on the <section> or the parent element itself
expect(handler).toHaveBeenCalledTimes(2);

//First call is for childTarget inner2 (grand child of parent)
expect(handler).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
target: clickTarget,
childTarget: screen.getByTestId("inner2"),
currentTarget: screen.getByTestId("parent"),
}),
);

//Second call is for childTarget mid1 (child of parent)
expect(handler).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
target: clickTarget,
childTarget: screen.getByTestId("mid1"),
currentTarget: screen.getByTestId("parent"),
}),
);

//WHEN click on mid1 handler is only called one time
handler.mockReset();
clickTarget = screen.getByTestId("mid1");
await userEvent.click(clickTarget);

//THEN
expect(handler).toHaveBeenCalledTimes(1);

expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
target: clickTarget,
childTarget: clickTarget,
currentTarget: screen.getByTestId("parent"),
}),
);
});
});
});
});
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"@fortawesome/fontawesome-free": "5.15.4",
"@monkeytype/oxlint-config": "workspace:*",
"@monkeytype/typescript-config": "workspace:*",
"@testing-library/dom": "10.4.1",
"@testing-library/user-event": "14.6.1",
"@types/canvas-confetti": "1.4.3",
"@types/chartjs-plugin-trendline": "1.0.1",
"@types/damerau-levenshtein": "1.0.0",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/ts/elements/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "@monkeytype/schemas/configs";
import Config, { setConfig } from "../config";
import * as Notifications from "../elements/notifications";
import { DomUtilsEvent, ElementWithUtils } from "../utils/dom";
import { ElementWithUtils } from "../utils/dom";

export type ValidationResult = {
status: "checking" | "success" | "failed" | "warning";
Expand Down Expand Up @@ -60,7 +60,7 @@ export function createInputEventHandler<T>(
callback: (result: ValidationResult) => void,
validation: Validation<T>,
inputValueConvert?: (val: string) => T,
): (e: DomUtilsEvent) => Promise<void> {
): (e: Event) => Promise<void> {
let callIsValid =
validation.isValid !== undefined
? debounceIfNeeded(
Expand Down
86 changes: 60 additions & 26 deletions frontend/src/ts/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,18 @@ type ElementWithValue =
| HTMLTextAreaElement
| HTMLSelectElement;

export type DomUtilsEvent<T extends Event = Event> = Omit<T, "currentTarget">;
//TODO: after the migration from jQuery to dom-utils we might want to add currentTarget back to the event object, if we have a use-case for it.
// For now we remove it because currentTarget is not the same element when using dom-utils intead of jQuery to get compile errors.
export type OnChildEvent<T extends Event = Event> = Omit<T, "currentTarget"> & {
/**
* target element matching the selector. This emulates the behavior of `currentTarget` in jQuery events registered with `.on(events, selector, handler)`
*/
childTarget: EventTarget | null;
};

type DomUtilsEventListenerOrEventListenerObject =
| { (evt: DomUtilsEvent): void }
| { handleEvent(object: DomUtilsEvent): void };
type OnChildEventListenerOrEventListenerObject =
| { (evt: OnChildEvent): void }
| { handleEvent(object: OnChildEvent): void };

export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
/**
Expand Down Expand Up @@ -244,56 +251,84 @@ export class ElementWithUtils<T extends HTMLElement = HTMLElement> {
*/
on<K extends keyof HTMLElementEventMap>(
event: K,
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
): this;
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
on(event: string, handler: EventListenerOrEventListenerObject): this;
on(
event: keyof HTMLElementEventMap | string,
handler:
| DomUtilsEventListenerOrEventListenerObject
| ((this: T, ev: DomUtilsEvent) => void),
| EventListenerOrEventListenerObject
| ((this: T, ev: Event) => void),
): this {
// this type was some AI magic but if it works it works
this.native.addEventListener(
event,
handler as DomUtilsEventListenerOrEventListenerObject,
handler as EventListenerOrEventListenerObject,
);
return this;
}

/**
* Attach an event listener to child elements matching the query.
* Attach an event listener to child elements matching the selector.
* Useful for dynamically added elements.
*
* The handler is not called when the event occurs directly on the bound element, but only for descendants (inner elements)
* that match the selector. Bubbles the event from the event target up to the element where the handler is attached
* (i.e., innermost to outermost element) and runs the handler for any elements along that path matching the selector.
*/
onChild<K extends keyof HTMLElementEventMap>(
event: K,
query: string,
/**
* A selector string to filter the descendants of the selected elements that will call the handler.
*/
selector: string,
handler: (
this: HTMLElement,
ev: DomUtilsEvent<HTMLElementEventMap[K]>,
ev: OnChildEvent<HTMLElementEventMap[K]>,
) => void,
): this;
onChild(
event: string,
query: string,
handler: DomUtilsEventListenerOrEventListenerObject,
/**
* A selector string to filter the descendants of the selected elements that will call the handler.
*/
selector: string,
handler: OnChildEventListenerOrEventListenerObject,
): this;
onChild(
event: keyof HTMLElementEventMap | string,
query: string,
/**
* A selector string to filter the descendants of the selected elements that will call the handler.
*/
selector: string,
handler:
| DomUtilsEventListenerOrEventListenerObject
| ((this: HTMLElement, ev: DomUtilsEvent) => void),
| OnChildEventListenerOrEventListenerObject
| ((this: HTMLElement, ev: OnChildEvent) => void),
): this {
// this type was some AI magic but if it works it works
this.native.addEventListener(event, (e) => {
const target = e.target as HTMLElement;
if (target !== null && target.matches(query)) {
if (target === null) return; //ignore event

let childTarget = target.closest(selector);
//bubble up until no match found or the parent element is reached
while (
childTarget !== null &&
childTarget !== this.native && //stop on parent
this.native.contains(childTarget) //stop above parent
) {
if (typeof handler === "function") {
handler.call(target, e);
handler.call(
childTarget as HTMLElement,
Object.assign(e, { childTarget }),
);
} else {
handler.handleEvent(e);
handler.handleEvent(Object.assign(e, { childTarget }));
}

childTarget =
childTarget.parentElement !== null
? childTarget.parentElement.closest(selector)
: null;
}
});
return this;
Expand Down Expand Up @@ -680,21 +715,20 @@ export class ElementsWithUtils<
*/
on<K extends keyof HTMLElementEventMap>(
event: K,
handler: (this: T, ev: DomUtilsEvent<HTMLElementEventMap[K]>) => void,
handler: (this: T, ev: HTMLElementEventMap[K]) => void,
): this;
on(event: string, handler: DomUtilsEventListenerOrEventListenerObject): this;
on(event: string, handler: EventListenerOrEventListenerObject): this;
on(
event: keyof HTMLElementEventMap | string,
handler:
| DomUtilsEventListenerOrEventListenerObject
| ((this: T, ev: DomUtilsEvent) => void),
| EventListenerOrEventListenerObject
| ((this: T, ev: Event) => void),
): this {
for (const item of this) {
item.on(event, handler);
}
return this;
}

/**
* Set attribute value on all elements in the array
*/
Expand Down
Loading
Loading