From 3163f1e80c515fcc84043b111e37f439e2d46a82 Mon Sep 17 00:00:00 2001 From: Jack Date: Sun, 11 Jan 2026 22:15:54 +0100 Subject: [PATCH 1/2] refactor: covert version button and version history modal to a component (@miodec, @fehmer) (#7343) Also adds a modal component. Also reworks mounting logic. --------- Co-authored-by: Christian Fehmer --- .../components/AnimatedModal.spec.tsx | 97 ++++++ .../components/AsyncContent.spec.tsx | 78 +++++ frontend/src/html/footer.html | 10 +- frontend/src/html/popups.html | 6 +- frontend/src/index.html | 1 + frontend/src/styles/footer.scss | 1 + frontend/src/styles/popups.scss | 68 ---- frontend/src/ts/components/AnimatedModal.tsx | 313 ++++++++++++++++++ frontend/src/ts/components/AsyncContent.tsx | 28 ++ .../src/ts/components/ModalChainContext.tsx | 72 ++++ frontend/src/ts/components/VersionButton.tsx | 59 ++++ .../ts/components/VersionHistoryModal.scss | 66 ++++ .../src/ts/components/VersionHistoryModal.tsx | 80 +++++ frontend/src/ts/components/mount.tsx | 19 +- frontend/src/ts/elements/version-button.ts | 25 -- frontend/src/ts/event-handlers/footer.ts | 28 -- frontend/src/ts/event-handlers/global.ts | 2 +- frontend/src/ts/hooks/useLocalStorage.ts | 87 +++++ frontend/src/ts/hooks/useRefWithUtils.ts | 17 + frontend/src/ts/index.ts | 4 +- frontend/src/ts/modals/version-history.ts | 61 ---- frontend/src/ts/signals/core.ts | 7 + frontend/src/ts/states/version.ts | 68 ---- frontend/src/ts/stores/modals.ts | 19 ++ frontend/src/ts/utils/version.ts | 47 +++ frontend/vitest.config.ts | 1 + 26 files changed, 997 insertions(+), 267 deletions(-) create mode 100644 frontend/__tests__/components/AnimatedModal.spec.tsx create mode 100644 frontend/__tests__/components/AsyncContent.spec.tsx create mode 100644 frontend/src/ts/components/AnimatedModal.tsx create mode 100644 frontend/src/ts/components/AsyncContent.tsx create mode 100644 frontend/src/ts/components/ModalChainContext.tsx create mode 100644 frontend/src/ts/components/VersionButton.tsx create mode 100644 frontend/src/ts/components/VersionHistoryModal.scss create mode 100644 frontend/src/ts/components/VersionHistoryModal.tsx delete mode 100644 frontend/src/ts/elements/version-button.ts create mode 100644 frontend/src/ts/hooks/useLocalStorage.ts create mode 100644 frontend/src/ts/hooks/useRefWithUtils.ts delete mode 100644 frontend/src/ts/modals/version-history.ts delete mode 100644 frontend/src/ts/states/version.ts create mode 100644 frontend/src/ts/stores/modals.ts create mode 100644 frontend/src/ts/utils/version.ts diff --git a/frontend/__tests__/components/AnimatedModal.spec.tsx b/frontend/__tests__/components/AnimatedModal.spec.tsx new file mode 100644 index 000000000000..1c10d9f573fe --- /dev/null +++ b/frontend/__tests__/components/AnimatedModal.spec.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@solidjs/testing-library"; +import { AnimatedModal } from "../../src/ts/components/AnimatedModal"; + +describe("AnimatedModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock dialog methods that don't exist in jsdom + HTMLDialogElement.prototype.showModal = vi.fn(); + HTMLDialogElement.prototype.show = vi.fn(); + HTMLDialogElement.prototype.close = vi.fn(); + }); + + function renderModal(props: { + isOpen: boolean; + onClose: () => void; + onEscape?: (e: KeyboardEvent) => void; + onBackdropClick?: (e: MouseEvent) => void; + class?: string; + beforeShow?: () => void | Promise; + afterShow?: () => void | Promise; + beforeHide?: () => void | Promise; + afterHide?: () => void | Promise; + animationMode?: "none" | "both" | "modalOnly"; + }): { + container: HTMLElement; + dialog: HTMLDialogElement; + modalDiv: HTMLDivElement; + } { + const { container } = render(() => ( + +
Test Content
+
+ )); + + return { + // oxlint-disable-next-line no-non-null-assertion + container: container.children[0]! as HTMLElement, + // oxlint-disable-next-line no-non-null-assertion + dialog: container.querySelector("dialog")!, + // oxlint-disable-next-line no-non-null-assertion + modalDiv: container.querySelector(".modal")!, + }; + } + + it("renders dialog with correct id and class", () => { + const { dialog } = renderModal({ isOpen: false, onClose: vi.fn() }); + + expect(dialog).toHaveAttribute("id", "TestModal"); + expect(dialog).toHaveClass("modalWrapper", "hidden"); + }); + + it("renders children inside modal div", () => { + const { modalDiv } = renderModal({ isOpen: false, onClose: vi.fn() }); + + expect( + modalDiv.querySelector("[data-testid='modal-content']"), + ).toHaveTextContent("Test Content"); + }); + + it("has escape handler attached", () => { + const onClose = vi.fn(); + + const { dialog } = renderModal({ isOpen: true, onClose }); + + expect(dialog.onkeydown).toBeDefined(); + }); + + it("has backdrop click handler attached", () => { + const onClose = vi.fn(); + + const { dialog } = renderModal({ isOpen: true, onClose }); + + expect(dialog.onmousedown).toBeDefined(); + }); + + it("applies custom class to dialog", () => { + const { dialog } = renderModal({ + isOpen: false, + onClose: vi.fn(), + class: "customClass", + }); + + expect(dialog).toHaveClass("modalWrapper", "hidden", "customClass"); + }); + + it("renders with animationMode none", () => { + const { dialog } = renderModal({ + isOpen: false, + onClose: vi.fn(), + animationMode: "none", + }); + + expect(dialog).toHaveAttribute("id", "TestModal"); + }); +}); diff --git a/frontend/__tests__/components/AsyncContent.spec.tsx b/frontend/__tests__/components/AsyncContent.spec.tsx new file mode 100644 index 000000000000..1868b8c7c1ca --- /dev/null +++ b/frontend/__tests__/components/AsyncContent.spec.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@solidjs/testing-library"; +import { createResource, Resource } from "solid-js"; +import AsyncContent from "../../src/ts/components/AsyncContent"; + +describe("AsyncContent", () => { + function renderWithResource( + resource: Resource, + errorMessage?: string, + ): { + container: HTMLElement; + } { + const { container } = render(() => ( + + {(data) =>
{String(data)}
} +
+ )); + + return { + container, + }; + } + + it("renders loading state while resource is pending", () => { + const [resource] = createResource(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return "data"; + }); + + const { container } = renderWithResource(resource); + + const preloader = container.querySelector(".preloader"); + expect(preloader).toBeInTheDocument(); + expect(preloader).toHaveClass("preloader"); + expect(preloader?.querySelector("i")).toHaveClass( + "fas", + "fa-fw", + "fa-spin", + "fa-circle-notch", + ); + }); + + it("renders data when resource resolves", async () => { + const [resource] = createResource(async () => { + return "Test Data"; + }); + + renderWithResource(resource); + + await waitFor(() => { + expect(screen.getByTestId("content")).toHaveTextContent("Test Data"); + }); + }); + + it("renders error message when resource fails", async () => { + const [resource] = createResource(async () => { + throw new Error("Test error"); + }); + + renderWithResource(resource, "Custom error message"); + + await waitFor(() => { + expect(screen.getByText(/Custom error message/)).toBeInTheDocument(); + }); + }); + + it("renders default error message when no custom message provided", async () => { + const [resource] = createResource(async () => { + throw new Error("Test error"); + }); + + renderWithResource(resource); + + await waitFor(() => { + expect(screen.getByText(/An error occurred/)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/html/footer.html b/frontend/src/html/footer.html index 25ff8906347b..fb49d6b6126a 100644 --- a/frontend/src/html/footer.html +++ b/frontend/src/html/footer.html @@ -80,15 +80,7 @@
serika dark
- - + diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 7dff29cceec5..205958504d9c 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -1,3 +1,5 @@ + + - +