From f31c45db0736a5fd05f885a235e172291064ea46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 28 May 2026 16:44:02 +0000 Subject: [PATCH 1/2] feat: add search filtering with text highlighting to dashboard Implement client-side search functionality for the records list with real-time text highlighting. Users can now filter records by searching across command, device name, and record ID fields. Features: - Search input positioned above device filter buttons - Case-insensitive substring matching across command, device, and ID fields - Real-time highlighting of matched text with tags - Highlight component for reusable text highlighting logic - Optimized filtering with useMemo to prevent unnecessary recalculations - Distinct empty states: "No records yet" vs "No matches found" - Dark theme consistent styling with focus states - 14 comprehensive TDD tests ensuring feature correctness Technical implementation: - Highlight component uses regex for case-insensitive matching - Escapes special regex characters for safe query handling - Filtered results computed via useMemo with [records, searchQuery] dependencies - Integrates seamlessly with existing device filtering - Yellow highlight (#fbbf24) with dark text for visibility on dark theme Resolves issue #7 Co-Authored-By: Claude Opus 4.6 --- packages/frontend/src/App.css | 20 +++ packages/frontend/src/App.test.tsx | 197 +++++++++++++++++++++++++++++ packages/frontend/src/App.tsx | 51 +++++++- 3 files changed, 264 insertions(+), 4 deletions(-) diff --git a/packages/frontend/src/App.css b/packages/frontend/src/App.css index 74888bf..54fd04b 100644 --- a/packages/frontend/src/App.css +++ b/packages/frontend/src/App.css @@ -57,6 +57,26 @@ h1 { color: #666; font-size: 0.85rem; } +.search-input { + width: 100%; + background: #2a2a4a; + color: #e0e0e0; + border: 1px solid #3a3a5a; + padding: 10px 14px; + border-radius: 6px; + font-size: 0.9rem; + margin-bottom: 12px; +} +.search-input:focus { + outline: none; + border-color: #7c8aff; +} +mark { + background: #fbbf24; + color: #1a1a2e; + padding: 1px 2px; + border-radius: 2px; +} .filters { display: flex; gap: 8px; diff --git a/packages/frontend/src/App.test.tsx b/packages/frontend/src/App.test.tsx index 4be6d7f..b03f6ec 100644 --- a/packages/frontend/src/App.test.tsx +++ b/packages/frontend/src/App.test.tsx @@ -26,3 +26,200 @@ describe("HTML Page Title", () => { expect(content).not.toContain("UWF Dashboard"); }); }); + +describe("Search Filtering with Highlight", () => { + it("should contain a search input element with class 'search-input'", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + expect(content).toMatch(/]*type=["'](text|search)["'][^>]*>/); + expect(content).toMatch(/className=["'][^"']*search-input[^"']*["']/); + expect(content).toMatch(/placeholder=["'][^"']*[Ss]earch[^"']*["']/); + }); + + it("should declare a search query state variable", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + expect(content).toMatch(/const\s+\[\s*\w+\s*,\s*set\w+\s*\]\s*=\s*useState[<\w>]*\(\s*["']["']?\s*\)/); + // Verify it's a string state for search + expect(content).toMatch(/useState\(["''"]|useState\(["''"]/); + }); + + it("should use useMemo to compute filtered records", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + expect(content).toContain("useMemo"); + // Check for dependency array containing records and search variable + expect(content).toMatch(/useMemo\([\s\S]+?,\s*\[\s*records\s*,\s*\w+\s*\]\s*\)/); + }); + + it("should implement case-insensitive filtering on command, device, and id fields", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for toLowerCase usage in filtering context + const lowerCaseCount = (content.match(/\.toLowerCase\(\)/g) || []).length; + expect(lowerCaseCount).toBeGreaterThanOrEqual(2); // At least for query and one field + + // Check for includes method (used for substring matching) + expect(content).toMatch(/\.includes\(/); + + // Verify filtering checks command, device, and id + expect(content).toMatch(/r\.command/); + expect(content).toMatch(/r\.device/); + expect(content).toMatch(/r\.id/); + }); + + it("should define a Highlight component that wraps matches with tags", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for Highlight component definition + expect(content).toMatch(/function\s+Highlight|const\s+Highlight\s*[:=]/); + + // Check for mark tag usage + expect(content).toContain("mark"); + + // Check for props handling (text and query or similar) + expect(content).toMatch(/\{\s*text\s*,\s*query\s*\}|\{\s*text\s*:\s*\w+\s*,\s*query\s*:\s*\w+\s*\}/); + }); + + it("should apply Highlight component to command column in record rows", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for Highlight usage with command field + expect(content).toMatch(/]*text=\{r\.command\}/); + + // Verify it's within the command span context + const commandSpanMatch = content.match(/]*className=["']command["'][^>]*>[\s\S]*?<\/span>/); + expect(commandSpanMatch).toBeTruthy(); + if (commandSpanMatch) { + expect(commandSpanMatch[0]).toContain("Highlight"); + } + }); + + it("should apply Highlight component to device badge in record rows", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for Highlight usage with device field + expect(content).toMatch(/]*text=\{r\.device\}/); + + // Verify it's within the device-badge span context + const deviceBadgeMatch = content.match(/]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/); + expect(deviceBadgeMatch).toBeTruthy(); + if (deviceBadgeMatch) { + expect(deviceBadgeMatch[0]).toContain("Highlight"); + } + }); + + it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for "No records yet" message + expect(content).toContain("No records yet"); + + // Check for "No matches" or similar search-specific empty message + expect(content).toMatch(/No matches|No search results|No records found/i); + }); + + it("should define .search-input styles consistent with dark theme", () => { + const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8"); + + // Check for .search-input selector + expect(cssContent).toMatch(/\.search-input\s*\{/); + + // Extract the search-input block + const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/); + expect(searchInputBlock).toBeTruthy(); + + if (searchInputBlock) { + const block = searchInputBlock[0]; + // Dark background + expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/); + // Light text + expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/); + // Border + expect(block).toMatch(/border:/); + // Padding + expect(block).toMatch(/padding:/); + // Border radius + expect(block).toMatch(/border-radius:/); + } + }); + + it("should define mark element styles with yellow/orange background for dark theme visibility", () => { + const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8"); + + // Check for mark selector + expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/); + + // Extract the mark block + const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/); + expect(markBlock).toBeTruthy(); + + if (markBlock) { + const block = markBlock[0]; + // Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.) + expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/); + // Should have color defined for text + expect(block).toMatch(/color:/); + } + }); + + it("should place search input above device filter buttons", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Extract the return JSX section + const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/); + expect(returnMatch).toBeTruthy(); + + if (returnMatch) { + const jsx = returnMatch[1]; + + // Find position of search input + const searchInputPos = jsx.search(/]*search-input/); + expect(searchInputPos).toBeGreaterThan(-1); + + // Find position of filters div + const filtersPos = jsx.search(/]*className=["']filters["']/); + expect(filtersPos).toBeGreaterThan(-1); + + // Search input should come before filters + expect(searchInputPos).toBeLessThan(filtersPos); + } + }); + + it("should render filtered records instead of raw records in the list", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for a filtered variable being mapped + const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/); + expect(useMemoMatch).toBeTruthy(); + + if (useMemoMatch) { + const filteredVar = useMemoMatch[1]; + + // Check that this variable is used in .map() + const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`); + expect(content).toMatch(mapPattern); + } + + // Additionally ensure raw records.map is NOT used in the render section + // (after the useMemo definition) + const useMemoIndex = content.indexOf("useMemo"); + const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/); + expect(recordsMapMatch).toBeNull(); + }); + + it("should handle empty search query gracefully", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check that filtering logic handles empty query + const useMemoBlock = content.match(/useMemo\([^}]+\}/); + expect(useMemoBlock).toBeTruthy(); + + // Should either have early return for empty query OR filter logic that handles it + expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/); + }); + + it("should import useMemo from React", () => { + const content = readFileSync(join(__dirname, "App.tsx"), "utf-8"); + + // Check for useMemo in React import (may have React, before the destructure) + expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/); + }); +}); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 8292804..b1a0da0 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef, useMemo } from "react"; import { useNavigate } from "react-router-dom"; interface Record { @@ -29,6 +29,21 @@ function fmtDuration(ms: number) { return `${(ms / 1000).toFixed(1)}s`; } +function Highlight({ text, query }: { text: string; query: string }) { + if (!query.trim()) return <>{text}; + + const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + const parts = text.split(regex); + + return ( + <> + {parts.map((part, i) => + regex.test(part) ? {part} : {part} + )} + + ); +} + export default function App() { const [records, setRecords] = useState([]); const [workers, setWorkers] = useState([]); @@ -36,9 +51,23 @@ export default function App() { [], ); const [filter, setFilter] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const nav = useNavigate(); const wsRef = useRef(null); + const filteredRecords = useMemo(() => { + if (!searchQuery.trim()) return records; + + const lowerQuery = searchQuery.toLowerCase(); + return records.filter((r) => { + return ( + r.command.toLowerCase().includes(lowerQuery) || + r.device.toLowerCase().includes(lowerQuery) || + r.id.toLowerCase().includes(lowerQuery) + ); + }); + }, [records, searchQuery]); + useEffect(() => { fetch(`/api/records${filter ? `?device=${filter}` : ""}`) .then((r) => r.json()) @@ -94,6 +123,13 @@ export default function App() { )} + setSearchQuery(e.target.value)} + />
{records.length === 0 &&
No records yet
} - {records.map((r) => ( + {records.length > 0 && filteredRecords.length === 0 && searchQuery && ( +
No matches found
+ )} + {filteredRecords.map((r) => (