From 1c7e6a9afb80ecc419988142d2811a2ff9feff22 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 22 Sep 2026 19:13:23 +0200 Subject: [PATCH 01/24] fix(knowledge-base): lock background scrolling when sidedrawer is open --- src/components/layout/DetailsSideDrawer.tsx | 3 ++ src/components/ui/SidePanel.tsx | 9 +++- .../components/ArtifactViewerDrawer.tsx | 4 ++ tests/unit/components/ui/SidePanel.test.tsx | 45 ++++++++++++++++++- .../components/ArtifactViewerDrawer.test.tsx | 10 ++++- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/components/layout/DetailsSideDrawer.tsx b/src/components/layout/DetailsSideDrawer.tsx index d008c5281..f16594288 100644 --- a/src/components/layout/DetailsSideDrawer.tsx +++ b/src/components/layout/DetailsSideDrawer.tsx @@ -17,6 +17,7 @@ type DetailsSideDrawerProps = { contentClassName?: string; headerClassName?: string; closeAriaLabel?: string; + lockScroll?: boolean; }; export function DetailsSideDrawer({ @@ -35,6 +36,7 @@ export function DetailsSideDrawer({ contentClassName = "mx-3 px-3 pb-8 pt-4 sm:mx-5 sm:px-4 sm:pb-10 sm:pt-5 lg:px-5 lg:pt-6", headerClassName = "mx-3 px-3 pb-4 pt-4 sm:mx-5 sm:px-4 sm:pb-5 sm:pt-5 lg:px-5 lg:pt-6", closeAriaLabel = "Close details", + lockScroll = true, }: DetailsSideDrawerProps) { return ( {children} diff --git a/src/components/ui/SidePanel.tsx b/src/components/ui/SidePanel.tsx index d9d3b19b4..7c913ad13 100644 --- a/src/components/ui/SidePanel.tsx +++ b/src/components/ui/SidePanel.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useId, useRef, type ReactNode } from "react"; import { SWIPE_IGNORE_ATTRIBUTE } from "../../hooks/useHorizontalWheelNavigation"; import { PanelPresenceContext } from "./panelPresenceContext"; import { sidePanelSlideToken } from "../../styles/tokens"; +import { useScrollLock } from "./useScrollLock"; type SidePanelProps = { isOpen: boolean; @@ -27,6 +28,7 @@ type SidePanelProps = { footerClassName?: string; closeAriaLabel?: string; closeOnEscape?: boolean; + lockScroll?: boolean; }; const focusableSelector = [ @@ -72,6 +74,7 @@ export function SidePanel({ footerClassName = "border-t border-app-border bg-app-bg px-6 py-5", closeAriaLabel = "Close details", closeOnEscape = true, + lockScroll = true, }: SidePanelProps) { const panelRef = useRef(null); const previouslyFocusedElement = useRef(null); @@ -83,6 +86,10 @@ export function SidePanel({ const presence = useContext(PanelPresenceContext); const isOpen = presence ? presence.isOpen : isOpenProp; + // Freezes the background page (body and any scroll containers) while the + // panel is open so only the panel insides scroll under the pointer. + useScrollLock(isOpen && lockScroll); + const prefersReducedMotion = useReducedMotion(); const panelTransition = prefersReducedMotion ? { duration: 0 } : sidePanelSlideToken; @@ -244,7 +251,7 @@ export function SidePanel({ )} -
+
{children}
diff --git a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx index 58741c5bb..33f2fa432 100644 --- a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx +++ b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx @@ -64,6 +64,8 @@ interface ArtifactViewerDrawerProps { /** Called after a successful deletion so the parent can clear the selection * and re-fetch the artifact list. */ onDelete: (artifactId: string) => void; + /** Whether to lock background scrolling while the drawer is open. Defaults to true. */ + lockScroll?: boolean; } type ViewMode = "raw" | "summary"; @@ -848,6 +850,7 @@ export function ArtifactViewerDrawer({ highlightLines, canDelete, onDelete, + lockScroll = true, }: ArtifactViewerDrawerProps) { const { profile } = useAuth(); const [state, dispatch] = useReducer(drawerReducer, initialState); @@ -1277,6 +1280,7 @@ export function ArtifactViewerDrawer({ panelBackgroundClassName="bg-app-surface" headerClassName="p-4 bg-app-bg" contentClassName="p-6" + lockScroll={lockScroll} > {error && viewMode === "raw" ? (
diff --git a/tests/unit/components/ui/SidePanel.test.tsx b/tests/unit/components/ui/SidePanel.test.tsx index 2a26b7fd2..d38d5722f 100644 --- a/tests/unit/components/ui/SidePanel.test.tsx +++ b/tests/unit/components/ui/SidePanel.test.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { render, screen, within, waitFor } from "@testing-library/react"; +import { render, screen, within, waitFor, cleanup } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { SidePanel } from "../../../../src/components/ui/SidePanel"; function SidePanelHarness({ @@ -15,6 +15,7 @@ function SidePanelHarness({ actions, footer, children, + lockScroll = true, }: { showOverlay?: boolean; closeOnEscape?: boolean; @@ -26,6 +27,7 @@ function SidePanelHarness({ actions?: React.ReactNode; footer?: React.ReactNode; children?: React.ReactNode; + lockScroll?: boolean; }) { const [isOpen, setIsOpen] = useState(false); return ( @@ -45,6 +47,7 @@ function SidePanelHarness({ showOverlay={showOverlay} closeOnEscape={closeOnEscape} closeAriaLabel={closeAriaLabel} + lockScroll={lockScroll} > {children ??

Panel content

} @@ -52,6 +55,12 @@ function SidePanelHarness({ ); } +afterEach(() => { + cleanup(); + document.body.style.overflow = ""; + document.body.style.paddingRight = ""; +}); + describe("SidePanel", () => { it("renders the dialog with aria-hidden and inert when closed", () => { render(); @@ -178,4 +187,36 @@ describe("SidePanel", () => { await waitFor(() => expect(dialog).toHaveAttribute("aria-hidden", "true")); }); + + it("locks document body scrolling when opened and restores it when closed", async () => { + const user = userEvent.setup(); + render(); + + expect(document.body.style.overflow).toBe(""); + + await user.click(screen.getByRole("button", { name: "Open" })); + expect(document.body.style.overflow).toBe("hidden"); + + await user.keyboard("{Escape}"); + await waitFor(() => expect(document.body.style.overflow).toBe("")); + }); + + it("does not lock scrolling when lockScroll is false", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Open" })); + expect(document.body.style.overflow).toBe(""); + }); + + it("applies overscroll-contain to the internal scroll container", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Open" })); + const dialog = screen.getByRole("dialog"); + const scrollContainer = dialog.querySelector(".overflow-y-auto"); + expect(scrollContainer).toBeInTheDocument(); + expect(scrollContainer).toHaveClass("overscroll-contain"); + }); }); diff --git a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx index 499f68bb0..82272d6f1 100644 --- a/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx +++ b/tests/unit/features/knowledge-base/components/ArtifactViewerDrawer.test.tsx @@ -50,15 +50,17 @@ vi.mock("../../../../../src/components/ui/SidePanel", () => ({ actions, badge, children, + lockScroll, }: { isOpen: boolean; title: React.ReactNode; actions: React.ReactNode; badge?: React.ReactNode; children: React.ReactNode; + lockScroll?: boolean; }) => isOpen ? ( -
+
{title}
{badge &&
{badge}
}
{actions}
@@ -999,4 +1001,10 @@ describe("ArtifactViewerDrawer", () => { vi.restoreAllMocks(); }); + + it("enables background scroll locking by default on the rendered SidePanel", () => { + renderDrawer(); + const panel = screen.getByTestId("side-panel"); + expect(panel).toHaveAttribute("data-lock-scroll", "true"); + }); }); From ab5971b113777cc2501c8e5dfcf198235cc429c4 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 22 Sep 2026 19:19:15 +0200 Subject: [PATCH 02/24] fix(scroll-lock): lock documentElement and prevent background wheel events while open --- src/components/ui/useScrollLock.ts | 83 ++++++++++++++++++- tests/unit/components/ui/SidePanel.test.tsx | 9 +- .../unit/components/ui/useScrollLock.test.tsx | 15 ++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/components/ui/useScrollLock.ts b/src/components/ui/useScrollLock.ts index b93f174fc..af856a586 100644 --- a/src/components/ui/useScrollLock.ts +++ b/src/components/ui/useScrollLock.ts @@ -59,7 +59,88 @@ export function useScrollLock(locked: boolean) { if (lockCount === 0) { const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - const restores = [lockElement(document.body, scrollbarWidth)]; + const restores = [ + lockElement(document.documentElement, 0), + lockElement(document.body, scrollbarWidth), + ]; + + function handleWheel(event: WheelEvent) { + let node: HTMLElement | null = event.target instanceof HTMLElement ? event.target : null; + let canScroll = false; + + while (node && node !== document.body && node !== document.documentElement) { + const style = window.getComputedStyle(node); + const isScrollable = + (style.overflowY === "auto" || style.overflowY === "scroll") && + node.scrollHeight > node.clientHeight; + + if (isScrollable) { + const isAtTop = node.scrollTop <= 0 && event.deltaY < 0; + const isAtBottom = + node.scrollTop + node.clientHeight >= node.scrollHeight - 1 && event.deltaY > 0; + + if (!isAtTop && !isAtBottom) { + canScroll = true; + break; + } + } + node = node.parentElement; + } + + if (!canScroll) { + event.preventDefault(); + } + } + + let touchStartY = 0; + + function handleTouchStart(event: TouchEvent) { + if (event.touches.length > 0) { + touchStartY = event.touches[0].clientY; + } + } + + function handleTouchMove(event: TouchEvent) { + if (event.touches.length === 0) return; + const currentY = event.touches[0].clientY; + const deltaY = touchStartY - currentY; + + let node: HTMLElement | null = event.target instanceof HTMLElement ? event.target : null; + let canScroll = false; + + while (node && node !== document.body && node !== document.documentElement) { + const style = window.getComputedStyle(node); + const isScrollable = + (style.overflowY === "auto" || style.overflowY === "scroll") && + node.scrollHeight > node.clientHeight; + + if (isScrollable) { + const isAtTop = node.scrollTop <= 0 && deltaY < 0; + const isAtBottom = + node.scrollTop + node.clientHeight >= node.scrollHeight - 1 && deltaY > 0; + + if (!isAtTop && !isAtBottom) { + canScroll = true; + break; + } + } + node = node.parentElement; + } + + if (!canScroll) { + event.preventDefault(); + } + } + + window.addEventListener("wheel", handleWheel, { passive: false }); + window.addEventListener("touchstart", handleTouchStart, { passive: true }); + window.addEventListener("touchmove", handleTouchMove, { passive: false }); + + restores.push(() => { + window.removeEventListener("wheel", handleWheel); + window.removeEventListener("touchstart", handleTouchStart); + window.removeEventListener("touchmove", handleTouchMove); + }); document .querySelectorAll(`[${SCROLL_CONTAINER_ATTRIBUTE}]`) diff --git a/tests/unit/components/ui/SidePanel.test.tsx b/tests/unit/components/ui/SidePanel.test.tsx index d38d5722f..9ee348f67 100644 --- a/tests/unit/components/ui/SidePanel.test.tsx +++ b/tests/unit/components/ui/SidePanel.test.tsx @@ -57,6 +57,7 @@ function SidePanelHarness({ afterEach(() => { cleanup(); + document.documentElement.style.overflow = ""; document.body.style.overflow = ""; document.body.style.paddingRight = ""; }); @@ -192,13 +193,18 @@ describe("SidePanel", () => { const user = userEvent.setup(); render(); + expect(document.documentElement.style.overflow).toBe(""); expect(document.body.style.overflow).toBe(""); await user.click(screen.getByRole("button", { name: "Open" })); + expect(document.documentElement.style.overflow).toBe("hidden"); expect(document.body.style.overflow).toBe("hidden"); await user.keyboard("{Escape}"); - await waitFor(() => expect(document.body.style.overflow).toBe("")); + await waitFor(() => { + expect(document.documentElement.style.overflow).toBe(""); + expect(document.body.style.overflow).toBe(""); + }); }); it("does not lock scrolling when lockScroll is false", async () => { @@ -206,6 +212,7 @@ describe("SidePanel", () => { render(); await user.click(screen.getByRole("button", { name: "Open" })); + expect(document.documentElement.style.overflow).toBe(""); expect(document.body.style.overflow).toBe(""); }); diff --git a/tests/unit/components/ui/useScrollLock.test.tsx b/tests/unit/components/ui/useScrollLock.test.tsx index 139db57a7..dd4f97682 100644 --- a/tests/unit/components/ui/useScrollLock.test.tsx +++ b/tests/unit/components/ui/useScrollLock.test.tsx @@ -12,6 +12,7 @@ function Locker({ locked = true }: { locked?: boolean }) { afterEach(() => { cleanup(); + document.documentElement.style.overflow = ""; document.body.style.overflow = ""; document.body.style.paddingRight = ""; }); @@ -19,9 +20,11 @@ afterEach(() => { describe("useScrollLock", () => { it("freezes the page while locked and restores it afterwards", () => { const view = render(); + expect(document.documentElement.style.overflow).toBe("hidden"); expect(document.body.style.overflow).toBe("hidden"); view.unmount(); + expect(document.documentElement.style.overflow).toBe(""); expect(document.body.style.overflow).toBe(""); }); @@ -68,4 +71,16 @@ describe("useScrollLock", () => { view.unmount(); expect(document.body.style.overflow).toBe("auto"); }); + + it("prevents wheel events on non-scrollable background elements while locked", () => { + const view = render(); + const event = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 100 }); + document.body.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + + view.unmount(); + const eventAfter = new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: 100 }); + document.body.dispatchEvent(eventAfter); + expect(eventAfter.defaultPrevented).toBe(false); + }); }); From f7259e60329f39648686e8b7c91690a34c3965cb Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Wed, 23 Sep 2026 14:51:20 +0200 Subject: [PATCH 03/24] feat(knowledge-base): add SourceLinkBadge component Renders an external link anchor for an artifact's source URL. Labels the link per source system (GitHub, Jira, Confluence, etc.) and falls back to a generic 'Open source' label for unknown systems. UPLOAD artifacts are excluded from the badge by convention. Includes unit tests covering all supported source system labels, null/whitespace URL guards, and link attribute correctness. --- .../components/SourceLinkBadge.tsx | 57 ++++++++++++++++ .../components/SourceLinkBadge.test.tsx | 66 +++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/features/knowledge-base/components/SourceLinkBadge.tsx create mode 100644 tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx diff --git a/src/features/knowledge-base/components/SourceLinkBadge.tsx b/src/features/knowledge-base/components/SourceLinkBadge.tsx new file mode 100644 index 000000000..da5885a3d --- /dev/null +++ b/src/features/knowledge-base/components/SourceLinkBadge.tsx @@ -0,0 +1,57 @@ +import { ExternalLink } from "lucide-react"; +import type { SourceSystem } from "../types"; + +/** + * Maps the artifact's source system to a user-friendly label for opening the resource. + */ +function getSourceSystemLabel(sourceSystem: SourceSystem): string { + switch (sourceSystem) { + case "GITHUB": + return "Open in GitHub"; + case "JIRA": + return "Open in Jira"; + case "CONFLUENCE": + return "Open in Confluence"; + default: + return "Open source"; + } +} + +const BADGE_CLASSES = + "inline-flex min-w-0 items-center gap-1.5 rounded-md border border-app-border bg-app-bg-soft px-2 py-0.5 text-[10px] font-bold text-app-text-muted transition-colors hover:border-app-brand/50 hover:bg-app-surface-hover hover:text-app-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-app-focus"; + +interface SourceLinkBadgeProps { + /** The destination URL for the artifact. */ + sourceUrl: string; + /** The origin system of the artifact. */ + sourceSystem: SourceSystem; + /** Targeting hook for end-to-end and unit tests. */ + testId?: string; +} + +/** + * Renders an external source link chip shared in the viewer drawer header, + * styled identically in shape, typography, and borders to RepositoryBadge. + */ +export function SourceLinkBadge({ + sourceUrl, + sourceSystem, + testId = "artifact-drawer-source-link", +}: SourceLinkBadgeProps) { + const label = getSourceSystemLabel(sourceSystem); + + return ( + + + ); +} diff --git a/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx new file mode 100644 index 000000000..9bd61755c --- /dev/null +++ b/tests/unit/features/knowledge-base/components/SourceLinkBadge.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { SourceLinkBadge } from "../../../../../src/features/knowledge-base/components/SourceLinkBadge"; + +describe("SourceLinkBadge", () => { + it("renders GitHub link badge with appropriate label and security attributes", () => { + render( + , + ); + + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("Open in GitHub"); + expect(link).toHaveAttribute("href", "https://github.com/org/repo/blob/main/doc.md"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(link).toHaveAttribute("aria-label", "Open in GitHub (opens in a new tab)"); + expect(link).toHaveAttribute("title", "https://github.com/org/repo/blob/main/doc.md"); + }); + + it("renders Jira link badge", () => { + render( + , + ); + + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toHaveTextContent("Open in Jira"); + expect(link).toHaveAttribute("href", "https://jira.example.com/browse/KEY-123"); + }); + + it("renders Confluence link badge", () => { + render( + , + ); + + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toHaveTextContent("Open in Confluence"); + }); + + it("falls back to generic label for other source systems", () => { + render( + , + ); + + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toHaveTextContent("Open source"); + }); + + it("respects custom testId prop", () => { + render( + , + ); + + expect(screen.getByTestId("custom-source-link")).toBeInTheDocument(); + }); +}); From 8d0f0fbc7d5587f40b5b94ec4da2c925fe844f78 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Wed, 23 Sep 2026 14:51:27 +0200 Subject: [PATCH 04/24] feat(knowledge-base): show SourceLinkBadge in ArtifactViewerDrawer header Renders the new SourceLinkBadge alongside the RepositoryBadge (for GitHub artifacts that have both) or in place of it (for Jira, Confluence, and other connector artifacts). UPLOAD artifacts and artifacts with a null/whitespace sourceUrl show neither. Updates the ArtifactViewerDrawer unit tests to cover all four cases: GitHub with repo + sourceUrl, Jira, Confluence, UPLOAD suppression, and null/whitespace URL suppression. --- .../components/ArtifactViewerDrawer.tsx | 22 ++++- .../components/ArtifactViewerDrawer.test.tsx | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx index 33f2fa432..449c86d3d 100644 --- a/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx +++ b/src/features/knowledge-base/components/ArtifactViewerDrawer.tsx @@ -39,6 +39,7 @@ import { import { getArtifactRepository } from "../githubMetadata"; import { knowledgeService } from "../../../services/knowledgeService"; import { RepositoryBadge } from "./RepositoryBadge"; +import { SourceLinkBadge } from "./SourceLinkBadge"; import { useToast } from "../../../context/useToast"; import { Button } from "../../../components/ui/Button"; import { ApiError } from "../../../services/apiClient"; @@ -1262,17 +1263,30 @@ export function ArtifactViewerDrawer({ // GitHub repo artifacts show their `owner/repository` in the header's badge // row; other kinds have no repository to name, so the badge stays unset. const repository = artifact ? getArtifactRepository(artifact) : null; + const hasSourceLink = Boolean(artifact?.sourceUrl?.trim()) && artifact?.sourceSystem !== "UPLOAD"; - const repositoryBadge = repository ? ( - - ) : undefined; + const headerBadge = + repository || hasSourceLink ? ( + <> + {repository && ( + + )} + {hasSourceLink && artifact?.sourceUrl && ( + + )} + + ) : undefined; return ( { }); }); + describe("external source link", () => { + it("shows the source link alongside the repository badge for a GitHub artifact with repo metadata and sourceUrl", () => { + const sourceUrl = "https://github.com/sprintstart/sprintstart-backend/blob/main/README.md"; + renderDrawer( + createArtifact({ + sourceSystem: "GITHUB", + sourceUrl, + metadata: JSON.stringify({ + repositoryId: "r1", + repositoryFullName: "sprintstart/sprintstart-backend", + }), + }), + ); + + expect(screen.getByTestId("artifact-drawer-repo-badge")).toHaveTextContent( + "sprintstart/sprintstart-backend", + ); + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("Open in GitHub"); + expect(link).toHaveAttribute("href", sourceUrl); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + expect(link).toHaveAttribute("title", sourceUrl); + }); + + it("shows the source link in place of the repository badge for a Jira artifact", () => { + const sourceUrl = "https://team.atlassian.net/browse/PROJ-123"; + renderDrawer( + createArtifact({ + sourceSystem: "JIRA", + sourceUrl, + }), + ); + + expect(screen.queryByTestId("artifact-drawer-repo-badge")).not.toBeInTheDocument(); + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("Open in Jira"); + expect(link).toHaveAttribute("href", sourceUrl); + }); + + it("shows the source link for a Confluence artifact", () => { + const sourceUrl = "https://team.atlassian.net/wiki/spaces/DEV/pages/456"; + renderDrawer( + createArtifact({ + sourceSystem: "CONFLUENCE", + sourceUrl, + }), + ); + + expect(screen.queryByTestId("artifact-drawer-repo-badge")).not.toBeInTheDocument(); + const link = screen.getByTestId("artifact-drawer-source-link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveTextContent("Open in Confluence"); + expect(link).toHaveAttribute("href", sourceUrl); + }); + + it("shows no source link for an uploaded artifact even if sourceUrl is present", () => { + renderDrawer( + createArtifact({ + sourceSystem: "UPLOAD", + sourceUrl: "https://example.com/uploads/doc.pdf", + }), + ); + + expect(screen.queryByTestId("artifact-drawer-source-link")).not.toBeInTheDocument(); + expect(screen.queryByTestId("artifact-drawer-repo-badge")).not.toBeInTheDocument(); + expect(screen.queryByTestId("panel-badge")).not.toBeInTheDocument(); + }); + + it("shows no source link when sourceUrl is null or whitespace", () => { + renderDrawer( + createArtifact({ + sourceSystem: "GITHUB", + sourceUrl: null, + }), + ); + expect(screen.queryByTestId("artifact-drawer-source-link")).not.toBeInTheDocument(); + + renderDrawer( + createArtifact({ + sourceSystem: "GITHUB", + sourceUrl: " ", + }), + ); + expect(screen.queryByTestId("artifact-drawer-source-link")).not.toBeInTheDocument(); + }); + }); + it("shows a spinner while fetching the summary", async () => { const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); vi.mocked(knowledgeService.streamArtifactSummary).mockReturnValue(new Promise(() => {})); From 94b1d120ce47b36f1dcfbbecff684f41938e1285 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Wed, 23 Sep 2026 14:51:36 +0200 Subject: [PATCH 05/24] fix(knowledge-base): preserve scroll position when artifact param changes Opening or closing the artifact drawer updates the ?artifact= search param via setSearchParams. useScrollRestoration was treating every search-param change as a navigation that warranted a scroll-to-top, causing the page to jump back to y=0 whenever a drawer was opened or closed on a scrolled page. Two-part fix: - KnowledgeBasePage: pass preventScrollReset:true to setSearchParams so the router does not reset scroll on param-only changes. - useScrollRestoration: only scroll-to-top on Push/Replace navigations when the pathname itself changed (or on the initial render), and respect the preventScrollReset location-state flag. Adds a useScrollRestoration unit test that asserts scroll position is retained across ?artifact param open/close cycles on the same pathname. --- src/hooks/useScrollRestoration.ts | 18 +++++- src/pages/KnowledgeBasePage.tsx | 2 +- .../unit/hooks/useScrollRestoration.test.tsx | 58 +++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/hooks/useScrollRestoration.ts b/src/hooks/useScrollRestoration.ts index 048960393..a97979457 100644 --- a/src/hooks/useScrollRestoration.ts +++ b/src/hooks/useScrollRestoration.ts @@ -64,17 +64,29 @@ export function useScrollRestoration(): void { const location = useLocation(); const navigationType = useNavigationType(); const pendingRestorationRef = useRef(null); + const prevPathnameRef = useRef(location.pathname); + const isFirstRenderRef = useRef(true); useEffect(() => { history.scrollRestoration = "manual"; }, []); useLayoutEffect(() => { + const isFirstRender = isFirstRenderRef.current; + isFirstRenderRef.current = false; const initialHost = getScrollHost(); + const pathnameChanged = prevPathnameRef.current !== location.pathname; + prevPathnameRef.current = location.pathname; + + const preventScrollReset = Boolean( + (location.state as { preventScrollReset?: boolean } | null)?.preventScrollReset, + ); if (navigationType !== NavigationType.Pop) { - pendingRestorationRef.current = null; - scrollHostTo(initialHost, 0); + if ((pathnameChanged || isFirstRender) && !preventScrollReset) { + pendingRestorationRef.current = null; + scrollHostTo(initialHost, 0); + } return; } @@ -151,7 +163,7 @@ export function useScrollRestoration(): void { pendingRestorationRef.current = null; } }; - }, [location.key, navigationType]); + }, [location.key, location.pathname, location.state, navigationType]); // Keeps the current page's position current for whenever the reader comes back to it. useEffect(() => { diff --git a/src/pages/KnowledgeBasePage.tsx b/src/pages/KnowledgeBasePage.tsx index 717a1c595..6f49284fe 100644 --- a/src/pages/KnowledgeBasePage.tsx +++ b/src/pages/KnowledgeBasePage.tsx @@ -137,7 +137,7 @@ export function KnowledgeBasePage() { if (selectedArtifactId) next.set("artifact", selectedArtifactId); else next.delete("artifact"); - setSearchParams(next, { replace: true }); + setSearchParams(next, { replace: true, preventScrollReset: true }); }, [selectedArtifactId, searchParams, setSearchParams]); // Reset active drawer selection whenever the project scope changes. diff --git a/tests/unit/hooks/useScrollRestoration.test.tsx b/tests/unit/hooks/useScrollRestoration.test.tsx index 9983a5f10..33f615ea9 100644 --- a/tests/unit/hooks/useScrollRestoration.test.tsx +++ b/tests/unit/hooks/useScrollRestoration.test.tsx @@ -47,4 +47,62 @@ describe("useScrollRestoration", () => { await waitFor(() => expect(scrollHost?.scrollTop).toBe(700)); }); + + it("does not reset scroll to 0 when search params change on the same pathname", async () => { + const { useSearchParams } = await import("react-router-dom"); + const { default: userEvent } = await import("@testing-library/user-event"); + const { screen } = await import("@testing-library/react"); + + function SearchParamsPage() { + useScrollRestoration(); + const [searchParams, setSearchParams] = useSearchParams(); + return ( +
{ + if (!node) return; + node.scrollTo = (xOrOptions?: ScrollToOptions | number, y?: number) => { + const target = typeof xOrOptions === "number" ? (y ?? 0) : (xOrOptions?.top ?? 0); + node.scrollTop = target; + }; + }} + > + {searchParams.get("artifact") ?? ""} + + +
+ ); + } + + const { container } = render( + + + , + ); + + const scrollHost = container.querySelector("main")!; + // Simulate user scrolled down + scrollHost.scrollTop = 500; + + // User opens artifact + const openBtn = screen.getByTestId("open-btn"); + await userEvent.click(openBtn); + + expect(screen.getByTestId("artifact-param")).toHaveTextContent("art-1"); + expect(scrollHost.scrollTop).toBe(500); + + // User closes artifact + const closeBtn = screen.getByTestId("close-btn"); + await userEvent.click(closeBtn); + + expect(screen.getByTestId("artifact-param")).toHaveTextContent(""); + expect(scrollHost.scrollTop).toBe(500); + }); }); From 0f5ec89c4e7ef5873890cd2baae8a8bb9d0bfead Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Wed, 23 Sep 2026 14:51:42 +0200 Subject: [PATCH 06/24] fix(ui): add preventScroll:true to SidePanel focus calls Focus management in SidePanel was calling .focus() without preventScroll:true. On some browsers this caused the page to scroll to bring the newly focused element into view when the panel opened or closed, overriding the scroll position the user was at. --- src/components/ui/SidePanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/SidePanel.tsx b/src/components/ui/SidePanel.tsx index 7c913ad13..c877a9ccc 100644 --- a/src/components/ui/SidePanel.tsx +++ b/src/components/ui/SidePanel.tsx @@ -95,7 +95,7 @@ export function SidePanel({ useEffect(() => { if (!isOpen) { - previouslyFocusedElement.current?.focus(); + previouslyFocusedElement.current?.focus({ preventScroll: true }); return; } @@ -107,7 +107,7 @@ export function SidePanel({ if (!panel) return; const [firstFocusable] = getFocusableElements(panel); - (firstFocusable ?? panel).focus(); + (firstFocusable ?? panel).focus({ preventScroll: true }); }); return () => { From 8c92f21d42f069b46603ec1e322e66d56782d84a Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Wed, 23 Sep 2026 14:51:54 +0200 Subject: [PATCH 07/24] fix(layout): prevent sidebar from disappearing when a SidePanel opens Root cause: the desktop sidebar used position:sticky. When SidePanel opens it calls useScrollLock, which applies overflow:hidden to . This turns into a scroll container with scrollTop=0. A sticky element's position is calculated relative to its nearest scroll container, so the sidebar snapped to document y=0 while the viewport was at y=400+, making it appear to scroll up or disappear entirely. Fix: - SideBar: change the desktop aside from sticky to position:fixed (top-0 bottom-0 left-0). Fixed elements are always positioned relative to the viewport and are immune to scroll-container changes. - App: add lg:ml-[286px] to
when signed in and not in focus mode, so the content area starts after the now out-of-flow sidebar. margin-left shifts the element box itself (padding-left would have left the box at x=0 and blocked pointer events on the sidebar). - useScrollLock: add scrollTop snapshot/restore to lockElement and restore window.scrollY after unlocking, so page position is preserved on panel close. --- src/App.tsx | 2 +- src/components/layout/SideBar.tsx | 2 +- src/components/ui/useScrollLock.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 68f5cf5c0..46e70ac62 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -107,7 +107,7 @@ function AppContent() { {/* `data-moment-stage`: the area the page-scoped moments (the onboarding launch and landing) cover, instead of the whole screen — see momentStage.ts in the moments feature. */} -
+
diff --git a/src/components/layout/SideBar.tsx b/src/components/layout/SideBar.tsx index 5a1f87647..697bd2786 100644 --- a/src/components/layout/SideBar.tsx +++ b/src/components/layout/SideBar.tsx @@ -460,7 +460,7 @@ export function SideBar() { <>
+ )} +
Per page - + {/* The field style is `w-full`; a `w-20` on the select itself would fight it. */} +
+ +
); } diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts index 155859885..7439279e9 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts @@ -13,6 +13,7 @@ import type { UploadFormat, } from "../types"; import { + DEFAULT_ARTIFACT_SORT, DEFAULT_FORMAT_ORDER, DEFAULT_SOURCE_ORDER, FORMAT_LABELS, @@ -101,6 +102,7 @@ export function useKnowledgeBase( toggleRepository, setPage, setSize, + setSort, clearFilters, setArtifactId, } = useKnowledgeBaseUrlState(projectId, options); @@ -112,6 +114,7 @@ export function useKnowledgeBase( repositories: selectedRepositories, page: requestedPage, size: pageSize, + sort, } = urlState; /* @@ -174,10 +177,14 @@ export function useKnowledgeBase( sources: sourcesParam, repositories: repositoriesParam, format: selectedFormat ?? undefined, + // The default order is left unsaid, so the default request (and its cache key, which the + // route prefetch warms) is byte-for-byte what it was before sorting existed. + sort: sort === DEFAULT_ARTIFACT_SORT ? undefined : sort, }), [ requestedPage, pageSize, + sort, searchParam, typesParam, sourcesParam, @@ -186,6 +193,7 @@ export function useKnowledgeBase( ], ); + // No page, size or sort: counts describe the whole filtered result, whatever its order. const facetsParams: KnowledgeListParams = useMemo( () => ({ search: searchParam, @@ -380,6 +388,7 @@ export function useKnowledgeBase( totalElements, resultRange, pageSize, + sort, handleSearchChange, handleTabChange, toggleSource, @@ -387,6 +396,8 @@ export function useKnowledgeBase( toggleRepository, setCurrentPage, setPageSize: setSize, + /** Changes the list order; pushes history and starts the new order at page 1. */ + setSort, handleClearFilters, hasActiveFilters, /** The artifact open in the viewer drawer (`?artifact=`), or null. */ diff --git a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts index 292dd63ec..37b6e2a81 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts @@ -1,8 +1,14 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { DEFAULT_FORMAT_ORDER, DEFAULT_SOURCE_ORDER, KNOWLEDGE_TAB_ORDER } from "../tabs.ts"; +import { + ARTIFACT_SORT_ORDER, + DEFAULT_ARTIFACT_SORT, + DEFAULT_FORMAT_ORDER, + DEFAULT_SOURCE_ORDER, + KNOWLEDGE_TAB_ORDER, +} from "../tabs.ts"; import type { KnowledgeTab } from "../tabs.ts"; -import type { SourceSystem, UploadFormat } from "../types.ts"; +import type { ArtifactSort, SourceSystem, UploadFormat } from "../types.ts"; /** Page size used when the URL names none. Mirrors the backend's list default. */ export const DEFAULT_PAGE_SIZE = 20; @@ -33,14 +39,15 @@ export const KB_URL_PARAM = { format: "format", page: "page", size: "size", + sort: "sort", artifact: "artifact", } as const; /** * The params that describe *one project's* corpus. A project switch drops them: a repository or * an open artifact from project A means nothing in project B, and the empty list it would produce - * reads as "this project has no knowledge". `size` is deliberately not in here — it is a reading - * preference, not a statement about the project's content. + * reads as "this project has no knowledge". `size` and `sort` are deliberately not in here — they + * are reading preferences, not statements about the project's content. */ const PROJECT_SCOPED_PARAMS: readonly string[] = [ KB_URL_PARAM.tab, @@ -52,7 +59,10 @@ const PROJECT_SCOPED_PARAMS: readonly string[] = [ KB_URL_PARAM.artifact, ]; -/** Everything "Clear filters" resets. Unlike a project switch it keeps the open artifact. */ +/** + * Everything "Clear filters" resets. Unlike a project switch it keeps the open artifact, and like + * one it keeps `size` and `sort`: an order is not a filter, and clearing must not reshuffle. + */ const FILTER_PARAMS: readonly string[] = [ KB_URL_PARAM.tab, KB_URL_PARAM.search, @@ -65,6 +75,7 @@ const FILTER_PARAMS: readonly string[] = [ const TAB_VALUES: ReadonlySet = new Set(KNOWLEDGE_TAB_ORDER); const SOURCE_VALUES: ReadonlySet = new Set(DEFAULT_SOURCE_ORDER); const FORMAT_VALUES: ReadonlySet = new Set(DEFAULT_FORMAT_ORDER); +const SORT_VALUES: ReadonlySet = new Set(ARTIFACT_SORT_ORDER); const NO_SOURCES: ReadonlySet = new Set(); const NO_STRINGS: ReadonlySet = new Set(); @@ -82,6 +93,8 @@ export interface KnowledgeBaseUrlState { /** 1-based. Not clamped to the result's page count here — the URL cannot know it. */ page: number; size: number; + /** List order; {@link DEFAULT_ARTIFACT_SORT} when the URL names none or an unknown one. */ + sort: ArtifactSort; /** The artifact open in the viewer drawer, if any. */ artifactId: string | null; } @@ -141,6 +154,10 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase const repositories: ReadonlySet = repositoryList.length > 0 ? new Set(repositoryList) : NO_STRINGS; + // An unknown order is dropped, not sent: the backend answers it with a 400. + const rawSort = params.get(KB_URL_PARAM.sort)?.trim().toUpperCase() ?? ""; + const sort = SORT_VALUES.has(rawSort) ? (rawSort as ArtifactSort) : DEFAULT_ARTIFACT_SORT; + return { tab, search: params.get(KB_URL_PARAM.search) ?? "", @@ -149,6 +166,7 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase format, page: readPositiveInt(params.get(KB_URL_PARAM.page), 1, Number.MAX_SAFE_INTEGER), size: readPositiveInt(params.get(KB_URL_PARAM.size), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE), + sort, artifactId: params.get(KB_URL_PARAM.artifact) || null, }; } @@ -226,7 +244,9 @@ export interface KnowledgeBaseUrlStateApi { setPage: (page: number, mode?: KnowledgeBaseHistoryMode) => void; /** Push-mode write of `?size=`, resetting the page. */ setSize: (size: number) => void; - /** Resets every filter (not the page size, not the open artifact) in one history entry. */ + /** Push-mode write of `?sort=` (omitted at the default), resetting the page. */ + setSort: (sort: ArtifactSort) => void; + /** Resets every filter (not size, not sort, not the open artifact) in one history entry. */ clearFilters: () => void; /** Opens (`id`) or closes (`null`) the viewer drawer, in replace mode. */ setArtifactId: (artifactId: string | null) => void; @@ -387,6 +407,17 @@ export function useKnowledgeBaseUrlState( [commit], ); + const setSort = useCallback( + (sort: ArtifactSort) => + commit((params) => { + if (sort === DEFAULT_ARTIFACT_SORT) params.delete(KB_URL_PARAM.sort); + else params.set(KB_URL_PARAM.sort, sort); + // Page 3 of a different order is an arbitrary slice; start the new order at its top. + params.delete(KB_URL_PARAM.page); + }, "push"), + [commit], + ); + const clearFilters = useCallback( () => commit((params) => { @@ -414,6 +445,7 @@ export function useKnowledgeBaseUrlState( toggleRepository, setPage, setSize, + setSort, clearFilters, setArtifactId, }; diff --git a/src/features/knowledge-base/tabs.ts b/src/features/knowledge-base/tabs.ts index 0d07d035c..f90e24906 100644 --- a/src/features/knowledge-base/tabs.ts +++ b/src/features/knowledge-base/tabs.ts @@ -1,4 +1,4 @@ -import type { Artifact, ArtifactType, SourceSystem, UploadFormat } from "./types"; +import type { Artifact, ArtifactSort, ArtifactType, SourceSystem, UploadFormat } from "./types"; /** * Upload file formats the knowledge base classifies an upload into. Declared @@ -98,6 +98,19 @@ export const FORMAT_LABELS: Record = { /** Standard order for the file-format facet. */ export const DEFAULT_FORMAT_ORDER: UploadFormat[] = ["PDF", "MARKDOWN", "IMAGE", "OTHER"]; +/** The list order used when the URL names none — and the one the backend applies unasked. */ +export const DEFAULT_ARTIFACT_SORT: ArtifactSort = "ADDED_DESC"; + +/** Display names for the sort control, phrased as what the reader sees first. */ +export const SORT_LABELS: Record = { + ADDED_DESC: "Newest added", + CHANGED_DESC: "Recently changed", + TITLE_ASC: "Title A–Z", +}; + +/** Order of the options in the sort control. */ +export const ARTIFACT_SORT_ORDER: ArtifactSort[] = ["ADDED_DESC", "CHANGED_DESC", "TITLE_ASC"]; + /** Whether an artifact came from a direct upload rather than a connector. */ export function isUpload(artifact: Artifact): boolean { return artifact.sourceSystem === "UPLOAD"; diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts index 2fb1e214c..f715ba3dc 100644 --- a/src/features/knowledge-base/types.ts +++ b/src/features/knowledge-base/types.ts @@ -131,6 +131,17 @@ export interface ArtifactFacets { repositories: FacetCount[]; } +/** + * Order of the artifact list (`?sort=` on the list endpoint; the facets endpoint has none). + * + * - `ADDED_DESC`: newest first by `ingestedAt` (the backend default). + * - `CHANGED_DESC`: most recently changed first, by `lastChangedAt` falling back to `ingestedAt`. + * - `TITLE_ASC`: title A–Z, case-insensitive, untitled artifacts last. + * + * Every order breaks ties on `id`, so paging never shows a row twice or skips one. + */ +export type ArtifactSort = "ADDED_DESC" | "CHANGED_DESC" | "TITLE_ASC"; + /** * Query parameters for filtering and paginating knowledge base artifacts. */ @@ -142,4 +153,9 @@ export interface KnowledgeListParams { sources?: SourceSystem[]; repositories?: string[]; format?: UploadFormat; + /** + * List order. Sent by `getArtifactPage` only — counts do not depend on order, and the facets + * endpoint does not accept it. Omitted means the backend default (`ADDED_DESC`). + */ + sort?: ArtifactSort; } diff --git a/src/pages/KnowledgeBasePage.tsx b/src/pages/KnowledgeBasePage.tsx index 94a8a2005..768605fa3 100644 --- a/src/pages/KnowledgeBasePage.tsx +++ b/src/pages/KnowledgeBasePage.tsx @@ -111,6 +111,8 @@ export function KnowledgeBasePage() { hasActiveFilters, selectedArtifactId, setSelectedArtifactId, + sort, + setSort, } = useKnowledgeBase(projectId, { projectSettled: !isProjectLoading }); const isLoading = isProjectLoading || isArtifactsLoading; @@ -231,6 +233,8 @@ export function KnowledgeBasePage() { resultRange={resultRange} hasActiveFilters={hasActiveFilters} onClearFilters={handleClearFilters} + sort={sort} + onSortChange={setSort} onRefresh={() => void fetchArtifacts()} isRefreshing={isLoading} /> diff --git a/src/services/knowledgeService.ts b/src/services/knowledgeService.ts index 1ea8bd060..ea3bcf712 100644 --- a/src/services/knowledgeService.ts +++ b/src/services/knowledgeService.ts @@ -33,6 +33,23 @@ type UploadResponseItem = { error?: string; }; +/** + * Serialises the *filter* half of {@link KnowledgeListParams} — the part the list and the facets + * endpoints share. One builder for both calls is the parity guarantee: a filter added here reaches + * the counts and the rows alike, so a facet can never promise "12" for a list that shows 30. + * Sets are repeated params (`types=A&types=B`), which is how Spring binds a `List` parameter. + */ +function buildFilterQuery(params: KnowledgeListParams): URLSearchParams { + const query = new URLSearchParams(); + const search = params.search?.trim(); + if (search) query.set("search", search); + for (const type of params.types ?? []) query.append("types", type); + for (const source of params.sources ?? []) query.append("sources", source); + for (const repository of params.repositories ?? []) query.append("repositories", repository); + if (params.format) query.set("format", params.format); + return query; +} + export const knowledgeService = { /** * Whether the project has anything ingested at all -- what an onboarding path is built from. @@ -79,30 +96,17 @@ export const knowledgeService = { * Fetches a paginated, server-side filtered page of artifacts for a project. * * @param projectId UUID of the project. - * @param params Query parameters including page, size, search, types, sources, repositories, format. + * @param params Filter criteria (see `buildFilterQuery`) plus the list-only page, size and sort. */ async getArtifactPage( projectId: string, params: KnowledgeListParams = {}, ): Promise { - const query = new URLSearchParams(); + const query = buildFilterQuery(params); if (params.page !== undefined) query.set("page", String(params.page)); if (params.size !== undefined) query.set("size", String(params.size)); - if (params.search && params.search.trim().length > 0) { - query.set("search", params.search.trim()); - } - if (params.types && params.types.length > 0) { - for (const t of params.types) query.append("types", t); - } - if (params.sources && params.sources.length > 0) { - for (const s of params.sources) query.append("sources", s); - } - if (params.repositories && params.repositories.length > 0) { - for (const r of params.repositories) query.append("repositories", r); - } - if (params.format) { - query.set("format", params.format); - } + // List-only: order changes which rows a page holds, never how many match. + if (params.sort) query.set("sort", params.sort); const queryString = query.toString(); const endpoint = `/api/v1/projects/${projectId}/artifacts${queryString ? `?${queryString}` : ""}`; @@ -112,6 +116,10 @@ export const knowledgeService = { /** * Fetches faceted counts for artifact types, source systems, upload formats, and repositories. * + * Sends exactly the filter criteria the list sends (see `buildFilterQuery`) and nothing of its + * paging or order: `page`, `size` and `sort` are ignored even when present in `params`, because + * a count must not depend on which page is on screen or how it is ordered. + * * @param projectId UUID of the project. * @param params Active filter criteria to calculate dynamic facet counts. */ @@ -119,24 +127,7 @@ export const knowledgeService = { projectId: string, params: KnowledgeListParams = {}, ): Promise { - const query = new URLSearchParams(); - if (params.search && params.search.trim().length > 0) { - query.set("search", params.search.trim()); - } - if (params.types && params.types.length > 0) { - for (const t of params.types) query.append("types", t); - } - if (params.sources && params.sources.length > 0) { - for (const s of params.sources) query.append("sources", s); - } - if (params.repositories && params.repositories.length > 0) { - for (const r of params.repositories) query.append("repositories", r); - } - if (params.format) { - query.set("format", params.format); - } - - const queryString = query.toString(); + const queryString = buildFilterQuery(params).toString(); const endpoint = `/api/v1/projects/${projectId}/artifacts/facets${queryString ? `?${queryString}` : ""}`; return apiClient.fetch(endpoint); }, diff --git a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx index 8c3e3a943..18e04547c 100644 --- a/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx +++ b/tests/unit/features/knowledge-base/components/ArtifactFilters.test.tsx @@ -302,4 +302,24 @@ describe("ArtifactFilters", () => { }); expect(screen.getByTestId("kb-filter-option-acme/repo-13")).toBeInTheDocument(); }); + it("offers the three list orders and reports a pick", () => { + const onSortChange = vi.fn(); + render(); + + const select = screen.getByTestId("kb-sort"); + expect(select).toHaveAccessibleName("Sort artifacts"); + expect(Array.from(select.options).map((option) => option.textContent)).toEqual([ + "Newest added", + "Recently changed", + "Title A–Z", + ]); + + fireEvent.change(select, { target: { value: "CHANGED_DESC" } }); + expect(onSortChange).toHaveBeenCalledWith("CHANGED_DESC"); + }); + + it("renders no sort control when the parent does not handle one", () => { + render(); + expect(screen.queryByTestId("kb-sort")).not.toBeInTheDocument(); + }); }); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts index aae2c9ba0..5ec5f2574 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts @@ -1233,3 +1233,39 @@ describe("useKnowledgeBase debounced search", () => { expect(result.current.kb.searchQuery).toBe("readme"); }); }); + +describe("useKnowledgeBase sort", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("asks the list for the chosen order and keeps it out of the facets request", async () => { + const { result } = await renderAt(["/kb"], makeFacetFixture()); + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + + act(() => result.current.kb.setSort("TITLE_ASC")); + + await waitFor(() => { + const calls = vi.mocked(knowledgeService.getArtifactPage).mock.calls; + expect(calls.at(-1)?.[1]).toMatchObject({ sort: "TITLE_ASC", page: 1 }); + }); + for (const [, params] of vi.mocked(knowledgeService.getArtifactFacets).mock.calls) { + expect(params).not.toHaveProperty("sort"); + } + expect(result.current.kb.sort).toBe("TITLE_ASC"); + expect(result.current.location.search).toBe("?sort=TITLE_ASC"); + }); + + it("does not send the default order, so the default request is what it always was", async () => { + const { getArtifactPage } = await renderAt(["/kb"], makeFacetFixture()); + + expect(getArtifactPage.mock.calls[0][1]).toEqual( + expect.objectContaining({ page: 1, size: 20, sort: undefined }), + ); + }); + + it("does not count an order as an active filter", async () => { + const { result } = await renderAt(["/kb?sort=CHANGED_DESC"], makeFacetFixture()); + expect(result.current.kb.hasActiveFilters).toBe(false); + }); +}); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts index 9df837817..fbf44d860 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts @@ -264,3 +264,44 @@ describe("useKnowledgeBaseUrlState project switches", () => { expect(result.current.location.search).toBe("?tab=FILE"); }); }); + +describe("useKnowledgeBaseUrlState sort", () => { + it("parses a known order case-insensitively and drops an unknown one", () => { + expect(parse("?sort=title_asc").sort).toBe("TITLE_ASC"); + expect(parse("?sort=CHANGED_DESC").sort).toBe("CHANGED_DESC"); + // The backend answers an unknown order with a 400; the URL must never be able to cause one. + expect(parse("?sort=POPULAR").sort).toBe("ADDED_DESC"); + expect(parse("").sort).toBe("ADDED_DESC"); + }); + + it("writes a non-default order, omits the default, and restarts at page 1", () => { + const { result } = renderUrlState(["/kb?page=4"], { projectId: "p1" }); + + act(() => result.current.api.setSort("CHANGED_DESC")); + expect(result.current.location.search).toBe("?sort=CHANGED_DESC"); + + act(() => result.current.api.setSort("ADDED_DESC")); + expect(result.current.location.search).toBe(""); + }); + + it("pushes a sort change so Back restores the previous order", () => { + const { result } = renderUrlState(["/kb"], { projectId: "p1" }); + + act(() => result.current.api.setSort("TITLE_ASC")); + act(() => void result.current.navigate(-1)); + + expect(result.current.api.state.sort).toBe("ADDED_DESC"); + }); + + it("keeps the order through Clear filters and a project switch", () => { + const { result, rerender } = renderUrlState(["/kb?sort=TITLE_ASC&sources=JIRA"], { + projectId: "p1", + }); + + act(() => result.current.api.clearFilters()); + expect(result.current.location.search).toBe("?sort=TITLE_ASC"); + + rerender({ projectId: "p2" }); + expect(result.current.api.state.sort).toBe("TITLE_ASC"); + }); +}); diff --git a/tests/unit/services/knowledgeService.test.ts b/tests/unit/services/knowledgeService.test.ts index ec2376d8b..0f48696f4 100644 --- a/tests/unit/services/knowledgeService.test.ts +++ b/tests/unit/services/knowledgeService.test.ts @@ -318,6 +318,74 @@ describe("knowledgeService", () => { }); }); + describe("list order and filter parity", () => { + const projectId = "proj-uuid"; + const emptyPage = { + items: [], + page: { + number: 0, + size: 20, + totalElements: 0, + totalPages: 0, + hasNext: false, + hasPrevious: false, + }, + }; + const emptyFacets = { types: [], sources: [], formats: [], repositories: [] }; + + it("sends the sort order with the list request", async () => { + let seen: URLSearchParams | null = null; + server.use( + http.get(`/api/v1/projects/${projectId}/artifacts`, ({ request }) => { + seen = new URL(request.url).searchParams; + return HttpResponse.json(emptyPage); + }), + ); + + await knowledgeService.getArtifactPage(projectId, { page: 0, size: 20, sort: "TITLE_ASC" }); + + expect(seen!.get("sort")).toBe("TITLE_ASC"); + }); + + it("leaves sort out when none is given, so the default request is unchanged", async () => { + let seen: URLSearchParams | null = null; + server.use( + http.get(`/api/v1/projects/${projectId}/artifacts`, ({ request }) => { + seen = new URL(request.url).searchParams; + return HttpResponse.json(emptyPage); + }), + ); + + await knowledgeService.getArtifactPage(projectId, { page: 1, size: 20 }); + + expect(seen!.has("sort")).toBe(false); + }); + + it("never sends page, size or sort to the facets endpoint", async () => { + let seen: URLSearchParams | null = null; + server.use( + http.get(`/api/v1/projects/${projectId}/artifacts/facets`, ({ request }) => { + seen = new URL(request.url).searchParams; + return HttpResponse.json(emptyFacets); + }), + ); + + await knowledgeService.getArtifactFacets(projectId, { + page: 3, + size: 50, + sort: "CHANGED_DESC", + search: "guide", + sources: ["GITHUB"], + }); + + expect(seen!.has("page")).toBe(false); + expect(seen!.has("size")).toBe(false); + expect(seen!.has("sort")).toBe(false); + expect(seen!.get("search")).toBe("guide"); + expect(seen!.getAll("sources")).toEqual(["GITHUB"]); + }); + }); + describe("getArtifactById", () => { const projectId = "proj-uuid"; const artifactId = "art-uuid"; From 7a2c6f3e5f7b7040bab52fef7f2232034ecaad2e Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Thu, 24 Sep 2026 10:34:04 +0200 Subject: [PATCH 16/24] upgrade(knowledge-base): filter artifacts by the date they were added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the Knowledge Base upgrade, frontend half. The pinned contract adds `from` and `to` (yyyy-MM-dd, both inclusive) to the list AND the facets endpoint, filtering on `ingestedAt` with UTC day boundaries; either end may be absent and `from > to` is a 400. Deviation from the plan, on purpose: the plan described an "Activity" filter on COALESCE(lastChangedAt, ingestedAt). The user-approved contract filters on `ingestedAt` only, and the contract overrides the plan. The UI therefore says "Added" everywhere ("Filter by date added", "Added since …", "Added from" / "Added to") - labelling it "Activity" or "Changed" would promise a predicate the server does not run. It also lines up with the sort labels of the previous commit ("Newest added" vs "Recently changed"). dateRange.ts (pure, tested in isolation) * `isIsoDate` - shape check plus a round trip through Date, because "2026-02-30" is well-formed but not a date and the backend would reject it. * `todayUtc` - today as the server counts days. Deliberately not the local date: west of UTC the local date can be a day behind, and a `to` of that date would end before "now" and hide the newest artifacts. The UTC day that contains now always contains now. * Presets (Last 7 / 30 / 90 days) resolve to ABSOLUTE dates at the moment they are picked (`resolvePreset`: from = today-(N-1), to = today, so "last 7 days" is 7 calendar days including today). Absolute on purpose: the URL then holds `from=2026-09-18&to=2026-09-24`, and a link shared today shows the same window next week instead of silently sliding with the calendar. `matchPreset` recognises a range as a preset only while it still equals one today; the same link a day later reads as a custom range. * `normalizeDateRange` - invalid ends dropped, a reversed pair swapped. Used by the URL parser and the writer, so nothing that reaches the request can be a 400: a hand-edited link with from > to is far more likely a slip than a request for nothing. * Display uses the artifact cards' date style with `timeZone: "UTC"`; without it every reader west of UTC would see the day before the one filtered on. Service `from`/`to` go into the shared `buildFilterQuery` from the previous commit, so they reach the list and the facets by construction. A service test pins exactly that difference against `sort` (list only). URL state `?from=` / `?to=` join the param map, are project-scoped (a switch drops them - project A's window says nothing about project B) and are filters ("Clear filters" drops them). `setDateRange(range, mode)` normalises, removes an open end, resets the page, and takes a history mode: presets and clearing push; typing into a date field replaces, so one custom edit is one history entry rather than one per completed segment of the native date control. Hook The filter criteria are now built once (`facetsParams`) and the list params are that object plus page/size/sort, mirroring the service's builder: the two requests cannot drift apart. `hasActiveFilters` includes the range; `dateRange`/`setDateRange` are exposed. UI: ArtifactDateRangeFilter (new, in the action row) * A native `ui/Select`: Any time / Last 7 days / Last 30 days / Last 90 days / Custom range…. Native for the same reason as the sort control. * "Custom range…" reveals two `ui/Input type="date"` fields ("Added from", "Added to") with min/max hints. A reversed pair is refused with a visible message, `aria-invalid` and `aria-describedby` on both fields, instead of being written - colour is not the only signal. The component keeps a local draft so a half-entered range is not pushed to the URL, and re-syncs the draft when the range changes from outside (Back, Clear filters, the chip). * An active range is named in a `ui/Badge` chip ("Added Sep 1, 2026 – Sep 24, 2026", "Added since …") with an icon-only "Clear date range" button next to it. * Wired into ArtifactFilters through optional `dateRange` / `onDateRangeChange` props (rendered only when a handler is given, like the sort control); a stable empty-range constant avoids re-syncing the filter every render when the prop is omitted. Tests * dateRange.test.ts: real-date check, UTC today (20:00 at UTC-8 is the next UTC day), month/year shifts, preset resolution, preset matching expiring a day later, normalisation (swap / drop), chip wording, UTC display. * ArtifactDateRangeFilter: preset -> absolute dates + push; select shows the matching preset or Custom; custom fields replace; reversed range refused with an accessible error; chip clear pushes; external clear returns to Any time. Plus an axe run with the custom fields and the error visible. * useKnowledgeBaseUrlState: parse (valid / invalid / reversed), write and page reset, push vs replace through Back, cleared by Clear filters and a project switch. * useKnowledgeBase: list and facets both receive the same from/to; a range is an active filter that Clear filters removes. * knowledgeService: from/to on both endpoints, sort on the list only. Gates: lint, format:check, build; vitest knowledge-base + page + service + KB/date a11y: 20 files / 280 tests. --- .../components/ArtifactDateRangeFilter.tsx | 165 ++++++++++++++++++ .../components/ArtifactFilters.tsx | 16 ++ src/features/knowledge-base/dateRange.ts | 115 ++++++++++++ .../knowledge-base/hooks/useKnowledgeBase.ts | 52 +++--- .../hooks/useKnowledgeBaseUrlState.ts | 37 ++++ src/features/knowledge-base/types.ts | 7 + src/pages/KnowledgeBasePage.tsx | 4 + src/services/knowledgeService.ts | 2 + .../ArtifactDateRangeFilter.a11y.test.tsx | 20 +++ .../ArtifactDateRangeFilter.test.tsx | 87 +++++++++ .../features/knowledge-base/dateRange.test.ts | 73 ++++++++ .../hooks/useKnowledgeBase.test.ts | 31 ++++ .../hooks/useKnowledgeBaseUrlState.test.ts | 54 ++++++ tests/unit/services/knowledgeService.test.ts | 30 ++++ 14 files changed, 668 insertions(+), 25 deletions(-) create mode 100644 src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx create mode 100644 src/features/knowledge-base/dateRange.ts create mode 100644 tests/unit/a11y/ArtifactDateRangeFilter.a11y.test.tsx create mode 100644 tests/unit/features/knowledge-base/components/ArtifactDateRangeFilter.test.tsx create mode 100644 tests/unit/features/knowledge-base/dateRange.test.ts diff --git a/src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx b/src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx new file mode 100644 index 000000000..cf254f0a5 --- /dev/null +++ b/src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx @@ -0,0 +1,165 @@ +import { useId, useState } from "react"; +import { X } from "lucide-react"; +import { Badge } from "../../../components/ui/Badge.tsx"; +import { Button } from "../../../components/ui/Button.tsx"; +import { Input } from "../../../components/ui/Input.tsx"; +import { Select } from "../../../components/ui/Select.tsx"; +import { + DATE_RANGE_PRESETS, + describeDateRange, + matchPreset, + resolvePreset, + type DateRange, +} from "../dateRange.ts"; +import type { KnowledgeBaseHistoryMode } from "../hooks/useKnowledgeBaseUrlState.ts"; + +const ANY = "ANY"; +const CUSTOM = "CUSTOM"; + +/** Props for {@link ArtifactDateRangeFilter}. */ +export interface ArtifactDateRangeFilterProps { + /** The active window, `yyyy-MM-dd` each end, null when open. */ + range: DateRange; + /** + * Fired with a valid, ordered range. `mode` is `"replace"` for typing into a date field (one edit, + * one history entry) and `"push"` for a preset or a clear. + */ + onRangeChange: (range: DateRange, mode: KnowledgeBaseHistoryMode) => void; + /** Clock override for tests; presets resolve against it. */ + now?: Date; +} + +/** + * The "Added" filter: a preset picker (Any time, Last 7/30/90 days, Custom range…) and, for a + * custom range, two native date fields. + * + * Presets are resolved to absolute dates when picked, so the URL holds a fixed window a link can + * share. The picker shows the preset a range still equals today, and "Custom range…" once it no + * longer does. A reversed custom pair is refused with a message rather than written: the backend + * answers `from > to` with a 400. + */ +export function ArtifactDateRangeFilter({ + range, + onRangeChange, + now, +}: ArtifactDateRangeFilterProps) { + const errorId = useId(); + const [isCustomOpen, setIsCustomOpen] = useState(false); + const [draft, setDraft] = useState(range); + const [syncedRange, setSyncedRange] = useState(range); + + // A range arriving from outside (Back, Clear filters, the chip) replaces whatever was drafted. + if (syncedRange.from !== range.from || syncedRange.to !== range.to) { + setSyncedRange(range); + setDraft(range); + if (!range.from && !range.to) setIsCustomOpen(false); + } + + const hasRange = range.from !== null || range.to !== null; + const preset = hasRange ? matchPreset(range, now) : null; + const selectValue = isCustomOpen ? CUSTOM : !hasRange ? ANY : (preset?.id ?? CUSTOM); + const isDraftReversed = draft.from !== null && draft.to !== null && draft.from > draft.to; + + const handlePresetChange = (value: string) => { + if (value === CUSTOM) { + setIsCustomOpen(true); + return; + } + setIsCustomOpen(false); + const picked = DATE_RANGE_PRESETS.find((option) => option.id === value); + onRangeChange(picked ? resolvePreset(picked, now) : { from: null, to: null }, "push"); + }; + + const handleDraftChange = (next: DateRange) => { + setDraft(next); + if (next.from !== null && next.to !== null && next.from > next.to) return; + onRangeChange(next, "replace"); + }; + + const chipLabel = describeDateRange(range); + + return ( +
+
+ +
+ + {selectValue === CUSTOM && ( +
+
+ + handleDraftChange({ ...draft, from: event.target.value || null }) + } + aria-label="Added from" + invalid={isDraftReversed} + aria-describedby={isDraftReversed ? errorId : undefined} + data-testid="kb-date-from" + /> +
+ +
+ handleDraftChange({ ...draft, to: event.target.value || null })} + aria-label="Added to" + invalid={isDraftReversed} + aria-describedby={isDraftReversed ? errorId : undefined} + data-testid="kb-date-to" + /> +
+ {isDraftReversed && ( +

+ The start date must be on or before the end date. +

+ )} +
+ )} + + {chipLabel && ( + + + {chipLabel} + + + + )} +
+ ); +} diff --git a/src/features/knowledge-base/components/ArtifactFilters.tsx b/src/features/knowledge-base/components/ArtifactFilters.tsx index de5d81649..2f8b7f7a0 100644 --- a/src/features/knowledge-base/components/ArtifactFilters.tsx +++ b/src/features/knowledge-base/components/ArtifactFilters.tsx @@ -34,6 +34,9 @@ import { type UploadFormat, } from "../tabs"; import type { ArtifactSort, ArtifactType, SourceSystem } from "../types"; +import type { DateRange } from "../dateRange.ts"; +import type { KnowledgeBaseHistoryMode } from "../hooks/useKnowledgeBaseUrlState.ts"; +import { ArtifactDateRangeFilter } from "./ArtifactDateRangeFilter.tsx"; import { formatResultRange } from "../resultRange.ts"; export type { ArtifactType, KnowledgeTab, SourceSystem, TabOption }; @@ -88,6 +91,10 @@ export interface ArtifactFiltersProps { sort?: ArtifactSort; /** Fired when the reader picks another order. The sort control is only shown when given. */ onSortChange?: (sort: ArtifactSort) => void; + /** The "Added" window on ingestion date; open ends are null. */ + dateRange?: DateRange; + /** Fired with a valid, ordered range. The date filter is only shown when given. */ + onDateRangeChange?: (range: DateRange, mode: KnowledgeBaseHistoryMode) => void; /** Fired when the user clicks the refresh button. */ onRefresh?: () => void; /** Whether a refresh is currently in progress. */ @@ -159,6 +166,9 @@ const REPOSITORY_VISIBLE_LIMIT = 10; * What the numbers beside each option mean. They are "what you would get if you * added this", not a project total, and nothing on screen said so. */ +/** Stable empty range, so an omitted prop does not re-sync the date filter every render. */ +const NO_DATE_RANGE: DateRange = { from: null, to: null }; + const FACET_COUNT_FOOTNOTE = "Counts show what you would get if you added this option."; /** @@ -193,6 +203,8 @@ export function ArtifactFilters({ isRefreshing, sort = DEFAULT_ARTIFACT_SORT, onSortChange, + dateRange = NO_DATE_RANGE, + onDateRangeChange, }: ArtifactFiltersProps) { const searchHintId = useId(); const sections: MultiSelectFilterSection[] = []; @@ -341,6 +353,10 @@ export function ArtifactFilters({ )} + {onDateRangeChange && ( + + )} + {onSortChange && ( // The field style is `w-full`, so the width lives on a wrapper, as the filter's does.
diff --git a/src/features/knowledge-base/dateRange.ts b/src/features/knowledge-base/dateRange.ts new file mode 100644 index 000000000..94e7ad535 --- /dev/null +++ b/src/features/knowledge-base/dateRange.ts @@ -0,0 +1,115 @@ +/** + * Date-range helpers for the Knowledge Base "Added" filter. + * + * The backend filters `ingestedAt` by whole days — `from` inclusive from the start of its day, + * `to` inclusive to the end of its day — and it cuts those days in UTC. Everything here therefore + * reasons in UTC calendar days and speaks `yyyy-MM-dd`, the only shape the endpoint accepts. + */ + +/** A range as it lives in the URL and the request; either end may be open. */ +export interface DateRange { + from: string | null; + to: string | null; +} + +/** A relative preset. It is resolved to absolute dates when picked (see {@link resolvePreset}). */ +export interface DateRangePreset { + id: string; + label: string; + /** Calendar days covered, today included. */ + days: number; +} + +/** The presets the filter offers, shortest first. */ +export const DATE_RANGE_PRESETS: readonly DateRangePreset[] = [ + { id: "7d", label: "Last 7 days", days: 7 }, + { id: "30d", label: "Last 30 days", days: 30 }, + { id: "90d", label: "Last 90 days", days: 90 }, +]; + +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Whether `value` is a real calendar date in `yyyy-MM-dd` form. `2026-02-30` is well-formed but + * not a date, and the backend would reject it, so the round trip through `Date` is the check. + */ +export function isIsoDate(value: string | null | undefined): value is string { + if (!value || !ISO_DATE.test(value)) return false; + const time = Date.parse(`${value}T00:00:00Z`); + return !Number.isNaN(time) && new Date(time).toISOString().slice(0, 10) === value; +} + +/** + * Today as the server counts days (UTC). Not the local date: for a reader west of UTC the local + * date can be a day behind, and a `to` of that date would end before "now" and hide the newest + * artifacts. The UTC day containing now always contains now. + */ +export function todayUtc(now: Date = new Date()): string { + return now.toISOString().slice(0, 10); +} + +/** `iso` moved by `days` calendar days (negative for the past). */ +export function shiftIsoDate(iso: string, days: number): string { + return new Date(Date.parse(`${iso}T00:00:00Z`) + days * DAY_MS).toISOString().slice(0, 10); +} + +/** + * Resolves a preset to absolute dates at the moment it is picked. Absolute on purpose: the URL + * records `from=2026-09-18&to=2026-09-24`, so a link shared today shows the same window next week + * instead of silently sliding along with the calendar. + */ +export function resolvePreset(preset: DateRangePreset, now: Date = new Date()): DateRange { + const to = todayUtc(now); + return { from: shiftIsoDate(to, -(preset.days - 1)), to }; +} + +/** The preset a range is exactly equal to *today*, if any. */ +export function matchPreset(range: DateRange, now: Date = new Date()): DateRangePreset | null { + if (!range.from || !range.to) return null; + return ( + DATE_RANGE_PRESETS.find((preset) => { + const resolved = resolvePreset(preset, now); + return resolved.from === range.from && resolved.to === range.to; + }) ?? null + ); +} + +/** + * Brings a range into the shape the backend accepts: invalid ends are dropped, and reversed ends + * are swapped — the backend answers `from > to` with a 400, and a reversed pair is far more likely + * a slip than a request for nothing. + */ +export function normalizeDateRange(range: DateRange): DateRange { + const from = isIsoDate(range.from) ? range.from : null; + const to = isIsoDate(range.to) ? range.to : null; + if (from && to && from > to) return { from: to, to: from }; + return { from, to }; +} + +const DISPLAY_FORMAT = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "numeric", + // The dates are UTC calendar days; formatting them in the local zone would show the day before + // for every reader west of UTC. + timeZone: "UTC", +}); + +/** `yyyy-MM-dd` for display, in the same style the artifact cards print their dates. */ +export function formatIsoDate(iso: string): string { + return DISPLAY_FORMAT.format(new Date(`${iso}T00:00:00Z`)); +} + +/** Words an active range for its chip: "Added Sep 1, 2026 – Sep 24, 2026", "Added since …". */ +export function describeDateRange(range: DateRange): string | null { + const { from, to } = range; + if (from && to) { + return from === to + ? `Added ${formatIsoDate(from)}` + : `Added ${formatIsoDate(from)} – ${formatIsoDate(to)}`; + } + if (from) return `Added since ${formatIsoDate(from)}`; + if (to) return `Added until ${formatIsoDate(to)}`; + return null; +} diff --git a/src/features/knowledge-base/hooks/useKnowledgeBase.ts b/src/features/knowledge-base/hooks/useKnowledgeBase.ts index 7439279e9..0760ada9e 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBase.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBase.ts @@ -103,6 +103,7 @@ export function useKnowledgeBase( setPage, setSize, setSort, + setDateRange, clearFilters, setArtifactId, } = useKnowledgeBaseUrlState(projectId, options); @@ -168,41 +169,37 @@ export function useKnowledgeBase( const searchParam = urlState.search.trim() || undefined; - const listParams: KnowledgeListParams = useMemo( + const dateFrom = urlState.dateRange.from ?? undefined; + const dateTo = urlState.dateRange.to ?? undefined; + + /* + The filter criteria, once, for both requests. The facets are counted over exactly this, and the + list pages through exactly this plus its own page, size and order: building the two from one + object is what keeps a facet count from describing a different predicate than the rows below. + */ + const facetsParams: KnowledgeListParams = useMemo( () => ({ - page: requestedPage, - size: pageSize, search: searchParam, types: typesParam, sources: sourcesParam, repositories: repositoriesParam, format: selectedFormat ?? undefined, - // The default order is left unsaid, so the default request (and its cache key, which the - // route prefetch warms) is byte-for-byte what it was before sorting existed. - sort: sort === DEFAULT_ARTIFACT_SORT ? undefined : sort, + from: dateFrom, + to: dateTo, }), - [ - requestedPage, - pageSize, - sort, - searchParam, - typesParam, - sourcesParam, - repositoriesParam, - selectedFormat, - ], + [searchParam, typesParam, sourcesParam, repositoriesParam, selectedFormat, dateFrom, dateTo], ); - // No page, size or sort: counts describe the whole filtered result, whatever its order. - const facetsParams: KnowledgeListParams = useMemo( + const listParams: KnowledgeListParams = useMemo( () => ({ - search: searchParam, - types: typesParam, - sources: sourcesParam, - repositories: repositoriesParam, - format: selectedFormat ?? undefined, + ...facetsParams, + page: requestedPage, + size: pageSize, + // The default order is left unsaid, so the default request (and its cache key, which the + // route prefetch warms) is byte-for-byte what it was before sorting existed. + sort: sort === DEFAULT_ARTIFACT_SORT ? undefined : sort, }), - [searchParam, typesParam, sourcesParam, repositoriesParam, selectedFormat], + [facetsParams, requestedPage, pageSize, sort], ); const listQueryKey = queryKeys.knowledgeBase.list(projectId ?? "", listParams); @@ -367,7 +364,9 @@ export function useKnowledgeBase( activeTab !== "ALL" || selectedSources.size > 0 || selectedFormat !== null || - selectedRepositories.size > 0; + selectedRepositories.size > 0 || + dateFrom !== undefined || + dateTo !== undefined; return { artifacts, @@ -398,6 +397,9 @@ export function useKnowledgeBase( setPageSize: setSize, /** Changes the list order; pushes history and starts the new order at page 1. */ setSort, + /** The "Added" window (`yyyy-MM-dd` ends, null when open); filters list and facets alike. */ + dateRange: urlState.dateRange, + setDateRange, handleClearFilters, hasActiveFilters, /** The artifact open in the viewer drawer (`?artifact=`), or null. */ diff --git a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts index 37b6e2a81..061157a12 100644 --- a/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts +++ b/src/features/knowledge-base/hooks/useKnowledgeBaseUrlState.ts @@ -9,6 +9,7 @@ import { } from "../tabs.ts"; import type { KnowledgeTab } from "../tabs.ts"; import type { ArtifactSort, SourceSystem, UploadFormat } from "../types.ts"; +import { normalizeDateRange, type DateRange } from "../dateRange.ts"; /** Page size used when the URL names none. Mirrors the backend's list default. */ export const DEFAULT_PAGE_SIZE = 20; @@ -40,6 +41,8 @@ export const KB_URL_PARAM = { page: "page", size: "size", sort: "sort", + from: "from", + to: "to", artifact: "artifact", } as const; @@ -55,6 +58,8 @@ const PROJECT_SCOPED_PARAMS: readonly string[] = [ KB_URL_PARAM.sources, KB_URL_PARAM.repositories, KB_URL_PARAM.format, + KB_URL_PARAM.from, + KB_URL_PARAM.to, KB_URL_PARAM.page, KB_URL_PARAM.artifact, ]; @@ -69,6 +74,8 @@ const FILTER_PARAMS: readonly string[] = [ KB_URL_PARAM.sources, KB_URL_PARAM.repositories, KB_URL_PARAM.format, + KB_URL_PARAM.from, + KB_URL_PARAM.to, KB_URL_PARAM.page, ]; @@ -95,6 +102,11 @@ export interface KnowledgeBaseUrlState { size: number; /** List order; {@link DEFAULT_ARTIFACT_SORT} when the URL names none or an unknown one. */ sort: ArtifactSort; + /** + * The "Added" window on `ingestedAt`, `yyyy-MM-dd` each end, open ends null. Always valid and + * ordered (see `normalizeDateRange`), so it can go to the backend as-is. + */ + dateRange: DateRange; /** The artifact open in the viewer drawer, if any. */ artifactId: string | null; } @@ -154,6 +166,11 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase const repositories: ReadonlySet = repositoryList.length > 0 ? new Set(repositoryList) : NO_STRINGS; + const dateRange = normalizeDateRange({ + from: params.get(KB_URL_PARAM.from), + to: params.get(KB_URL_PARAM.to), + }); + // An unknown order is dropped, not sent: the backend answers it with a 400. const rawSort = params.get(KB_URL_PARAM.sort)?.trim().toUpperCase() ?? ""; const sort = SORT_VALUES.has(rawSort) ? (rawSort as ArtifactSort) : DEFAULT_ARTIFACT_SORT; @@ -167,6 +184,7 @@ export function parseKnowledgeBaseSearch(params: URLSearchParams): KnowledgeBase page: readPositiveInt(params.get(KB_URL_PARAM.page), 1, Number.MAX_SAFE_INTEGER), size: readPositiveInt(params.get(KB_URL_PARAM.size), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE), sort, + dateRange, artifactId: params.get(KB_URL_PARAM.artifact) || null, }; } @@ -246,6 +264,11 @@ export interface KnowledgeBaseUrlStateApi { setSize: (size: number) => void; /** Push-mode write of `?sort=` (omitted at the default), resetting the page. */ setSort: (sort: ArtifactSort) => void; + /** + * Writes `?from=`/`?to=` (normalised; open ends removed), resetting the page. Presets and + * "clear" push; typing into a date field replaces, so one edit is one history entry. + */ + setDateRange: (range: DateRange, mode?: KnowledgeBaseHistoryMode) => void; /** Resets every filter (not size, not sort, not the open artifact) in one history entry. */ clearFilters: () => void; /** Opens (`id`) or closes (`null`) the viewer drawer, in replace mode. */ @@ -418,6 +441,19 @@ export function useKnowledgeBaseUrlState( [commit], ); + const setDateRange = useCallback( + (range: DateRange, mode: KnowledgeBaseHistoryMode = "push") => + commit((params) => { + const { from, to } = normalizeDateRange(range); + if (from) params.set(KB_URL_PARAM.from, from); + else params.delete(KB_URL_PARAM.from); + if (to) params.set(KB_URL_PARAM.to, to); + else params.delete(KB_URL_PARAM.to); + params.delete(KB_URL_PARAM.page); + }, mode), + [commit], + ); + const clearFilters = useCallback( () => commit((params) => { @@ -446,6 +482,7 @@ export function useKnowledgeBaseUrlState( setPage, setSize, setSort, + setDateRange, clearFilters, setArtifactId, }; diff --git a/src/features/knowledge-base/types.ts b/src/features/knowledge-base/types.ts index f715ba3dc..451d9edb9 100644 --- a/src/features/knowledge-base/types.ts +++ b/src/features/knowledge-base/types.ts @@ -158,4 +158,11 @@ export interface KnowledgeListParams { * endpoint does not accept it. Omitted means the backend default (`ADDED_DESC`). */ sort?: ArtifactSort; + /** + * Earliest ingestion day, `yyyy-MM-dd`, inclusive (UTC day boundaries on the server). Sent to + * the list and the facets alike. + */ + from?: string; + /** Latest ingestion day, `yyyy-MM-dd`, inclusive. Sent to the list and the facets alike. */ + to?: string; } diff --git a/src/pages/KnowledgeBasePage.tsx b/src/pages/KnowledgeBasePage.tsx index 768605fa3..291781428 100644 --- a/src/pages/KnowledgeBasePage.tsx +++ b/src/pages/KnowledgeBasePage.tsx @@ -113,6 +113,8 @@ export function KnowledgeBasePage() { setSelectedArtifactId, sort, setSort, + dateRange, + setDateRange, } = useKnowledgeBase(projectId, { projectSettled: !isProjectLoading }); const isLoading = isProjectLoading || isArtifactsLoading; @@ -235,6 +237,8 @@ export function KnowledgeBasePage() { onClearFilters={handleClearFilters} sort={sort} onSortChange={setSort} + dateRange={dateRange} + onDateRangeChange={setDateRange} onRefresh={() => void fetchArtifacts()} isRefreshing={isLoading} /> diff --git a/src/services/knowledgeService.ts b/src/services/knowledgeService.ts index ea3bcf712..7f6f22feb 100644 --- a/src/services/knowledgeService.ts +++ b/src/services/knowledgeService.ts @@ -47,6 +47,8 @@ function buildFilterQuery(params: KnowledgeListParams): URLSearchParams { for (const source of params.sources ?? []) query.append("sources", source); for (const repository of params.repositories ?? []) query.append("repositories", repository); if (params.format) query.set("format", params.format); + if (params.from) query.set("from", params.from); + if (params.to) query.set("to", params.to); return query; } diff --git a/tests/unit/a11y/ArtifactDateRangeFilter.a11y.test.tsx b/tests/unit/a11y/ArtifactDateRangeFilter.a11y.test.tsx new file mode 100644 index 000000000..f616e00ad --- /dev/null +++ b/tests/unit/a11y/ArtifactDateRangeFilter.a11y.test.tsx @@ -0,0 +1,20 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { axe } from "vitest-axe"; +import { ArtifactDateRangeFilter } from "../../../src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx"; + +describe("ArtifactDateRangeFilter a11y", () => { + it("has no violations with a custom range open and a reversed-range error shown", async () => { + const { container } = render( + , + ); + fireEvent.change(screen.getByLabelText("Added to"), { target: { value: "2026-09-01" } }); + expect(screen.getByTestId("kb-date-error")).toBeInTheDocument(); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/tests/unit/features/knowledge-base/components/ArtifactDateRangeFilter.test.tsx b/tests/unit/features/knowledge-base/components/ArtifactDateRangeFilter.test.tsx new file mode 100644 index 000000000..3ddc6a1db --- /dev/null +++ b/tests/unit/features/knowledge-base/components/ArtifactDateRangeFilter.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ArtifactDateRangeFilter } from "../../../../../src/features/knowledge-base/components/ArtifactDateRangeFilter.tsx"; +import type { DateRange } from "../../../../../src/features/knowledge-base/dateRange.ts"; + +const NOW = new Date("2026-09-24T10:00:00Z"); +const OPEN: DateRange = { from: null, to: null }; + +function renderFilter(range: DateRange = OPEN) { + const onRangeChange = vi.fn(); + const view = render( + , + ); + return { onRangeChange, ...view }; +} + +describe("ArtifactDateRangeFilter", () => { + it("resolves a preset to absolute dates when it is picked, and pushes it", () => { + const { onRangeChange } = renderFilter(); + + fireEvent.change(screen.getByTestId("kb-date-preset"), { target: { value: "7d" } }); + + expect(onRangeChange).toHaveBeenCalledWith({ from: "2026-09-18", to: "2026-09-24" }, "push"); + }); + + it("shows the preset a range still equals, and Custom once it does not", () => { + const { rerender } = renderFilter({ from: "2026-09-18", to: "2026-09-24" }); + expect(screen.getByTestId("kb-date-preset")).toHaveValue("7d"); + + rerender( + , + ); + expect(screen.getByTestId("kb-date-preset")).toHaveValue("CUSTOM"); + expect(screen.getByTestId("kb-date-from")).toHaveValue("2026-01-01"); + }); + + it("reveals two date fields for a custom range and replaces on each edit", () => { + const { onRangeChange } = renderFilter(); + + fireEvent.change(screen.getByTestId("kb-date-preset"), { target: { value: "CUSTOM" } }); + expect(onRangeChange).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText("Added from"), { target: { value: "2026-09-01" } }); + expect(onRangeChange).toHaveBeenLastCalledWith({ from: "2026-09-01", to: null }, "replace"); + }); + + it("refuses a reversed custom range with a message instead of writing it", () => { + const { onRangeChange } = renderFilter({ from: "2026-09-10", to: null }); + + fireEvent.change(screen.getByLabelText("Added to"), { target: { value: "2026-09-01" } }); + + expect(onRangeChange).not.toHaveBeenCalled(); + const error = screen.getByTestId("kb-date-error"); + expect(error).toHaveTextContent("on or before"); + expect(screen.getByLabelText("Added to")).toHaveAttribute("aria-describedby", error.id); + expect(screen.getByLabelText("Added to")).toHaveAttribute("aria-invalid", "true"); + }); + + it("names the active range in a chip whose button clears it", () => { + const { onRangeChange } = renderFilter({ from: "2026-09-01", to: null }); + + expect(screen.getByTestId("kb-date-chip")).toHaveTextContent(/Added since/); + fireEvent.click(screen.getByRole("button", { name: "Clear date range" })); + + expect(onRangeChange).toHaveBeenCalledWith({ from: null, to: null }, "push"); + }); + + it("shows no chip and Any time while no range is set", () => { + renderFilter(); + expect(screen.getByTestId("kb-date-preset")).toHaveValue("ANY"); + expect(screen.queryByTestId("kb-date-chip")).not.toBeInTheDocument(); + }); + + it("goes back to Any time when the range is cleared from outside", () => { + const { rerender } = renderFilter({ from: "2026-01-01", to: "2026-02-01" }); + expect(screen.getByTestId("kb-date-preset")).toHaveValue("CUSTOM"); + + rerender(); + + expect(screen.getByTestId("kb-date-preset")).toHaveValue("ANY"); + expect(screen.queryByTestId("kb-date-from")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/unit/features/knowledge-base/dateRange.test.ts b/tests/unit/features/knowledge-base/dateRange.test.ts new file mode 100644 index 000000000..21becf239 --- /dev/null +++ b/tests/unit/features/knowledge-base/dateRange.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + DATE_RANGE_PRESETS, + describeDateRange, + isIsoDate, + matchPreset, + normalizeDateRange, + resolvePreset, + shiftIsoDate, + todayUtc, +} from "../../../../src/features/knowledge-base/dateRange.ts"; + +const NOW = new Date("2026-09-24T10:00:00Z"); +const [LAST_7, LAST_30] = DATE_RANGE_PRESETS; + +describe("dateRange", () => { + it("accepts only real yyyy-MM-dd calendar dates", () => { + expect(isIsoDate("2026-09-24")).toBe(true); + expect(isIsoDate("2024-02-29")).toBe(true); + expect(isIsoDate("2026-02-30")).toBe(false); + expect(isIsoDate("2026-9-24")).toBe(false); + expect(isIsoDate("24.09.2026")).toBe(false); + expect(isIsoDate(null)).toBe(false); + }); + + it("counts today in UTC, the zone the server cuts days in", () => { + // 20:00 in UTC-8 is already the next day in UTC; a `to` of the local date would end too early. + expect(todayUtc(new Date("2026-09-24T20:00:00-08:00"))).toBe("2026-09-25"); + }); + + it("shifts dates across month and year boundaries", () => { + expect(shiftIsoDate("2026-03-01", -1)).toBe("2026-02-28"); + expect(shiftIsoDate("2026-12-31", 1)).toBe("2027-01-01"); + }); + + it("resolves a preset to an absolute window that includes today", () => { + expect(resolvePreset(LAST_7, NOW)).toEqual({ from: "2026-09-18", to: "2026-09-24" }); + expect(resolvePreset(LAST_30, NOW)).toEqual({ from: "2026-08-26", to: "2026-09-24" }); + }); + + it("recognises a range as a preset only while it still equals one today", () => { + expect(matchPreset({ from: "2026-09-18", to: "2026-09-24" }, NOW)?.id).toBe("7d"); + // The same link a day later is a fixed, custom window. + expect( + matchPreset({ from: "2026-09-18", to: "2026-09-24" }, new Date("2026-09-25T10:00:00Z")), + ).toBeNull(); + expect(matchPreset({ from: "2026-09-18", to: null }, NOW)).toBeNull(); + }); + + it("drops invalid ends and swaps a reversed pair instead of sending a 400", () => { + expect(normalizeDateRange({ from: "2026-09-30", to: "2026-09-01" })).toEqual({ + from: "2026-09-01", + to: "2026-09-30", + }); + expect(normalizeDateRange({ from: "nope", to: "2026-09-01" })).toEqual({ + from: null, + to: "2026-09-01", + }); + }); + + it("describes closed, open and single-day ranges", () => { + expect(describeDateRange({ from: null, to: null })).toBeNull(); + expect(describeDateRange({ from: "2026-09-01", to: "2026-09-24" })).toMatch(/^Added .+ – .+$/); + expect(describeDateRange({ from: "2026-09-01", to: null })).toMatch(/^Added since .*2026/); + expect(describeDateRange({ from: null, to: "2026-09-24" })).toMatch(/^Added until .*2026/); + expect(describeDateRange({ from: "2026-09-24", to: "2026-09-24" })).not.toContain("–"); + }); + + it("prints the UTC calendar day, not the local day before it", () => { + // `Date` parses a bare date as UTC midnight; a local-zone format would say Aug 31 west of UTC. + expect(describeDateRange({ from: "2026-09-01", to: null })).toMatch(/\b1\b/); + }); +}); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts index 5ec5f2574..68b03063e 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBase.test.ts @@ -1269,3 +1269,34 @@ describe("useKnowledgeBase sort", () => { expect(result.current.kb.hasActiveFilters).toBe(false); }); }); + +describe("useKnowledgeBase date range", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("filters the list and the facets by the same window", async () => { + const { result } = await renderAt(["/kb"], makeFacetFixture()); + const { knowledgeService } = await import("../../../../../src/services/knowledgeService"); + + act(() => result.current.kb.setDateRange({ from: "2026-09-01", to: "2026-09-24" })); + + await waitFor(() => { + const list = vi.mocked(knowledgeService.getArtifactPage).mock.calls.at(-1)?.[1]; + const facets = vi.mocked(knowledgeService.getArtifactFacets).mock.calls.at(-1)?.[1]; + expect(list).toMatchObject({ from: "2026-09-01", to: "2026-09-24", page: 1 }); + expect(facets).toMatchObject({ from: "2026-09-01", to: "2026-09-24" }); + }); + expect(result.current.kb.dateRange).toEqual({ from: "2026-09-01", to: "2026-09-24" }); + }); + + it("counts a date range as an active filter that Clear filters removes", async () => { + const { result } = await renderAt(["/kb?from=2026-09-01"], makeFacetFixture()); + expect(result.current.kb.hasActiveFilters).toBe(true); + + act(() => result.current.kb.handleClearFilters()); + + expect(result.current.kb.hasActiveFilters).toBe(false); + expect(result.current.kb.dateRange).toEqual({ from: null, to: null }); + }); +}); diff --git a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts index fbf44d860..45de7a414 100644 --- a/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts +++ b/tests/unit/features/knowledge-base/hooks/useKnowledgeBaseUrlState.test.ts @@ -305,3 +305,57 @@ describe("useKnowledgeBaseUrlState sort", () => { expect(result.current.api.state.sort).toBe("TITLE_ASC"); }); }); + +describe("useKnowledgeBaseUrlState date range", () => { + it("reads a valid range, drops invalid ends and swaps a reversed pair", () => { + expect(parse("?from=2026-09-01&to=2026-09-24").dateRange).toEqual({ + from: "2026-09-01", + to: "2026-09-24", + }); + expect(parse("?from=2026-02-30&to=2026-09-24").dateRange).toEqual({ + from: null, + to: "2026-09-24", + }); + // The backend 400s on from > to; a hand-edited link must not be able to trigger it. + expect(parse("?from=2026-09-24&to=2026-09-01").dateRange).toEqual({ + from: "2026-09-01", + to: "2026-09-24", + }); + expect(parse("").dateRange).toEqual({ from: null, to: null }); + }); + + it("writes the range, restarts at page 1, and removes an open end", () => { + const { result } = renderUrlState(["/kb?page=3"], { projectId: "p1" }); + + act(() => result.current.api.setDateRange({ from: "2026-09-01", to: "2026-09-24" })); + expect(result.current.location.search).toBe("?from=2026-09-01&to=2026-09-24"); + + act(() => result.current.api.setDateRange({ from: "2026-09-01", to: null })); + expect(result.current.location.search).toBe("?from=2026-09-01"); + }); + + it("pushes by default and replaces when asked", () => { + const { result } = renderUrlState(["/start", "/kb"], { projectId: "p1" }); + + act(() => result.current.api.setDateRange({ from: "2026-09-01", to: null })); + act(() => result.current.api.setDateRange({ from: "2026-09-02", to: null }, "replace")); + act(() => void result.current.navigate(-1)); + + // One push, one replace: Back leaves the range entirely instead of stepping through edits. + expect(result.current.location.pathname).toBe("/kb"); + expect(result.current.api.state.dateRange.from).toBeNull(); + }); + + it("is cleared by Clear filters and by a project switch", () => { + const { result, rerender } = renderUrlState(["/kb?from=2026-09-01&to=2026-09-24"], { + projectId: "p1", + }); + + act(() => result.current.api.clearFilters()); + expect(result.current.location.search).toBe(""); + + act(() => result.current.api.setDateRange({ from: "2026-09-01", to: null })); + rerender({ projectId: "p2" }); + expect(result.current.api.state.dateRange).toEqual({ from: null, to: null }); + }); +}); diff --git a/tests/unit/services/knowledgeService.test.ts b/tests/unit/services/knowledgeService.test.ts index 0f48696f4..79a14d059 100644 --- a/tests/unit/services/knowledgeService.test.ts +++ b/tests/unit/services/knowledgeService.test.ts @@ -384,6 +384,36 @@ describe("knowledgeService", () => { expect(seen!.get("search")).toBe("guide"); expect(seen!.getAll("sources")).toEqual(["GITHUB"]); }); + + it("sends the date range to the list and the facets alike, unlike sort", async () => { + let listParams: URLSearchParams | null = null; + let facetParams: URLSearchParams | null = null; + server.use( + http.get(`/api/v1/projects/${projectId}/artifacts`, ({ request }) => { + listParams = new URL(request.url).searchParams; + return HttpResponse.json(emptyPage); + }), + http.get(`/api/v1/projects/${projectId}/artifacts/facets`, ({ request }) => { + facetParams = new URL(request.url).searchParams; + return HttpResponse.json(emptyFacets); + }), + ); + const params = { + from: "2026-09-01", + to: "2026-09-24", + sort: "TITLE_ASC" as const, + }; + + await knowledgeService.getArtifactPage(projectId, params); + await knowledgeService.getArtifactFacets(projectId, params); + + for (const seen of [listParams!, facetParams!]) { + expect(seen.get("from")).toBe("2026-09-01"); + expect(seen.get("to")).toBe("2026-09-24"); + } + expect(listParams!.get("sort")).toBe("TITLE_ASC"); + expect(facetParams!.has("sort")).toBe(false); + }); }); describe("getArtifactById", () => { From 4c5898b880d6a6a4f29ca8a447d7153477c6c1e1 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Thu, 24 Sep 2026 11:10:00 +0200 Subject: [PATCH 17/24] upgrade(knowledge-base): filter artifacts by programming language Adds the language facet from phase 6 of the KB plan. The backend now returns Artifact.language (display name derived from the file extension) and a `languages` facet; this commit makes both usable. URL: `?languages=` (comma list), project-scoped and a filter param like `sources`/`repos`, so it is dropped on a project switch, cleared by Clear filters and resets the page on change. Parsing de-duplicates ignoring case and the toggle folds case too: the backend matches case-insensitively, so a hand-typed `kotlin` must be undone by unticking "Kotlin", or the reader could not remove a filter they see. API: `languages` is sent repeated to the list AND the facets (it narrows the row set, so every count must respect it), via the shared buildFilterQuery, so the two requests cannot drift apart. Options: facet values with a count plus every selected value (count 0 when the facet omits it), matched ignoring case; a selected entry keeps the URL's spelling as its value so unticking hits it. Sorted alphabetically, not by count, so ticking one never reshuffles the list. The section is not gated on a source (a language narrows every source) and stays hidden when the project has no language values; `ArtifactFacets.languages` is optional so an older backend reads as "no languages" instead of crashing. ArtifactFilters prefixes language values with `lang:` inside the one shared selected set: languages are free strings, and an unprefixed "Markdown" would be mistaken for the MARKDOWN format both in the toggle routing and in the option test ids. The trigger summary names one language or counts several, like repositories. tabs.ts already read `language === "markdown"` when classifying uploads; now that the field is actually populated, tabs.test.ts pins that a Markdown upload without an extension is MARKDOWN and that "Kotlin"/"Plain Text" uploads fall to OTHER, while connector artifacts stay out of the format facet's scope. The mocked service in useKnowledgeBase.test.ts learns `languages` (own dimension excluded, document kinds dropped, selected zero-count returned) per plan risk 5, so tests assert the real request. --- .../components/ArtifactFilters.tsx | 60 +++++++++- .../knowledge-base/hooks/useKnowledgeBase.ts | 51 ++++++++- .../hooks/useKnowledgeBaseUrlState.ts | 49 ++++++++ src/features/knowledge-base/tabs.ts | 6 + src/features/knowledge-base/types.ts | 17 +++ src/pages/KnowledgeBasePage.tsx | 8 +- src/services/knowledgeService.ts | 4 +- .../components/ArtifactFilters.test.tsx | 72 ++++++++++++ .../hooks/useKnowledgeBase.test.ts | 107 +++++++++++++++++- .../hooks/useKnowledgeBaseUrlState.test.ts | 37 ++++++ .../unit/features/knowledge-base/tabs.test.ts | 41 +++++++ tests/unit/services/knowledgeService.test.ts | 22 ++++ 12 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 tests/unit/features/knowledge-base/tabs.test.ts diff --git a/src/features/knowledge-base/components/ArtifactFilters.tsx b/src/features/knowledge-base/components/ArtifactFilters.tsx index 2f8b7f7a0..2f8c1c66d 100644 --- a/src/features/knowledge-base/components/ArtifactFilters.tsx +++ b/src/features/knowledge-base/components/ArtifactFilters.tsx @@ -8,6 +8,7 @@ import { FolderGit2, GitBranch, Image as ImageIcon, + Languages, RefreshCw, Search, Ticket, @@ -76,6 +77,14 @@ export interface ArtifactFiltersProps { onToggleSource: (source: SourceSystem) => void; onToggleFormat: (format: UploadFormat) => void; onToggleRepository: (repository: string) => void; + /** + * Languages the project's artifacts are written in, with counts. Not gated on a + * source, and empty (section hidden) when the project has no language values. + * Optional so surfaces that predate the facet keep compiling; the page wires it. + */ + languageOptions?: FacetOption[]; + selectedLanguages?: ReadonlySet; + onToggleLanguage?: (language: string) => void; /** Total count of matching artifacts. */ resultCount: number; /** @@ -124,6 +133,7 @@ function summariseSources( sources: ReadonlySet, format: UploadFormat | null, repositories: ReadonlySet, + languages: ReadonlySet, ): string { const parts: string[] = []; @@ -148,6 +158,12 @@ function summariseSources( parts.push(`${repositories.size} repositories`); } + if (languages.size === 1) { + parts.push([...languages][0]); + } else if (languages.size > 1) { + parts.push(`${languages.size} languages`); + } + return parts.join(" · "); } @@ -169,6 +185,15 @@ const REPOSITORY_VISIBLE_LIMIT = 10; /** Stable empty range, so an omitted prop does not re-sync the date filter every render. */ const NO_DATE_RANGE: DateRange = { from: null, to: null }; +/** + * Prefix for language values inside the shared multi-select. Languages are free + * strings, so an unprefixed "Markdown" (reachable by a hand-typed URL) would be + * mistaken for the MARKDOWN format in the one selected set and in test ids. + */ +const LANGUAGE_VALUE_PREFIX = "lang:"; +const NO_LANGUAGES: ReadonlySet = new Set(); +const NO_LANGUAGE_OPTIONS: FacetOption[] = []; + const FACET_COUNT_FOOTNOTE = "Counts show what you would get if you added this option."; /** @@ -195,6 +220,9 @@ export function ArtifactFilters({ onToggleSource, onToggleFormat, onToggleRepository, + languageOptions = NO_LANGUAGE_OPTIONS, + selectedLanguages = NO_LANGUAGES, + onToggleLanguage, resultCount, resultRange, hasActiveFilters, @@ -253,17 +281,38 @@ export function ArtifactFilters({ }); } + if (languageOptions.length > 0) { + sections.push({ + id: "languages", + label: "Language", + options: languageOptions.map((option) => ({ + value: `${LANGUAGE_VALUE_PREFIX}${option.value}`, + label: option.label, + count: option.count, + icon: