feat: add search filtering with text highlighting to dashboard #8

Merged
xiaonuo merged 2 commits from fix/7-search-filtering-with-highlight into main 2026-05-28 22:57:10 +00:00
3 changed files with 276 additions and 4 deletions
+20
View File
@@ -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;
+205
View File
@@ -26,3 +26,208 @@ describe("HTML Page Title", () => {
expect(content).not.toContain("<title>UWF Dashboard</title>");
});
});
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(/<input[^>]*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<string>\(["''"]|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 <mark> 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(/<Highlight[^>]*text=\{r\.command\}/);
// Verify it's within the command span context
const commandSpanMatch = content.match(
/<span[^>]*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(/<Highlight[^>]*text=\{r\.device\}/);
// Verify it's within the device-badge span context
const deviceBadgeMatch = content.match(
/<span[^>]*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(/<input[^>]*search-input/);
expect(searchInputPos).toBeGreaterThan(-1);
// Find position of filters div
const filtersPos = jsx.search(/<div[^>]*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["']/);
});
});
+51 -4
View File
@@ -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,25 @@ 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) ? (
<mark key={`${i}-${part}`}>{part}</mark>
) : (
<span key={`${i}-${part}`}>{part}</span>
),
)}
</>
);
}
export default function App() {
const [records, setRecords] = useState<Record[]>([]);
const [workers, setWorkers] = useState<Worker[]>([]);
@@ -36,9 +55,23 @@ export default function App() {
[],
);
const [filter, setFilter] = useState("");
const [searchQuery, setSearchQuery] = useState<string>("");
const nav = useNavigate();
const wsRef = useRef<WebSocket | null>(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 +127,13 @@ export default function App() {
)}
</div>
</header>
<input
type="text"
className="search-input"
placeholder="Search records by command, device, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="filters">
<button type="button" className={!filter ? "active" : ""} onClick={() => setFilter("")}>
All
@@ -111,15 +151,22 @@ export default function App() {
</div>
<div className="records">
{records.length === 0 && <div className="empty">No records yet</div>}
{records.map((r) => (
{records.length > 0 && filteredRecords.length === 0 && searchQuery && (
<div className="empty">No matches found</div>
)}
{filteredRecords.map((r) => (
<button
type="button"
key={r.id}
className="record-row"
onClick={() => nav(`/record/${r.id}`)}
>
<span className="device-badge">{r.device}</span>
<span className="command">{r.command}</span>
<span className="device-badge">
<Highlight text={r.device} query={searchQuery} />
</span>
<span className="command">
<Highlight text={r.command} query={searchQuery} />
</span>
<span className={`exit-code ${r.exitCode === 0 ? "success" : "error"}`}>
{r.exitCode === 0 ? "✓" : `${r.exitCode}`}
</span>