feat: add search filtering with text highlighting to dashboard #8
@@ -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;
|
||||
|
||||
@@ -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["']/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user