Skip to content

Commit 4cd9b01

Browse files
committed
frontend: add search for devices
1 parent 1f678da commit 4cd9b01

File tree

8 files changed

+453
-39
lines changed

8 files changed

+453
-39
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useTranslation } from "react-i18next";
2+
import { Form, InputGroup } from "react-bootstrap";
3+
import { Search, X } from "react-feather";
4+
5+
interface SearchInputProps {
6+
searchTerm: string;
7+
onSearchChange: (searchTerm: string) => void;
8+
placeholder?: string;
9+
}
10+
11+
export function SearchInput({ searchTerm, onSearchChange, placeholder }: SearchInputProps) {
12+
const { t } = useTranslation("", { useSuspense: false, keyPrefix: "chargers" });
13+
14+
const handleClear = () => {
15+
onSearchChange("");
16+
};
17+
18+
return (
19+
<InputGroup className="mb-3">
20+
<InputGroup.Text>
21+
<Search size={16} />
22+
</InputGroup.Text>
23+
<Form.Control
24+
type="text"
25+
placeholder={placeholder || t("search_devices_placeholder")}
26+
value={searchTerm}
27+
onChange={(e) => onSearchChange((e.target as HTMLInputElement).value)}
28+
aria-label={t("search_devices")}
29+
/>
30+
{searchTerm && (
31+
<InputGroup.Text
32+
onClick={handleClear}
33+
style={{ cursor: "pointer" }}
34+
title={t("clear_search")}
35+
>
36+
<X size={16} />
37+
</InputGroup.Text>
38+
)}
39+
</InputGroup>
40+
);
41+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { render, screen, fireEvent } from '@testing-library/preact';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { SearchInput } from '../SearchInput';
4+
5+
describe('SearchInput', () => {
6+
const defaultProps = {
7+
searchTerm: '',
8+
onSearchChange: vi.fn(),
9+
};
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks();
13+
});
14+
15+
it('renders search input with default placeholder', () => {
16+
render(<SearchInput {...defaultProps} />);
17+
18+
expect(screen.getByRole('textbox')).toBeInTheDocument();
19+
expect(screen.getByDisplayValue('')).toBeInTheDocument();
20+
});
21+
22+
it('renders search input with custom placeholder', () => {
23+
const customPlaceholder = 'Custom search placeholder';
24+
render(<SearchInput {...defaultProps} placeholder={customPlaceholder} />);
25+
26+
const input = screen.getByRole('textbox');
27+
expect(input).toHaveAttribute('placeholder', customPlaceholder);
28+
});
29+
30+
it('displays current search term', () => {
31+
render(<SearchInput {...defaultProps} searchTerm="test search" />);
32+
33+
expect(screen.getByDisplayValue('test search')).toBeInTheDocument();
34+
});
35+
36+
it('calls onSearchChange when typing', () => {
37+
const onSearchChange = vi.fn();
38+
render(<SearchInput {...defaultProps} onSearchChange={onSearchChange} />);
39+
40+
const input = screen.getByRole('textbox') as HTMLInputElement;
41+
42+
// Simulate typing by setting the value and triggering the change event
43+
input.value = 'new search term';
44+
fireEvent.change(input);
45+
46+
expect(onSearchChange).toHaveBeenCalledWith('new search term');
47+
});
48+
49+
it('shows clear button when search term is not empty', () => {
50+
render(<SearchInput {...defaultProps} searchTerm="search text" />);
51+
52+
// The X icon should be visible
53+
expect(screen.getByTestId('x-icon')).toBeInTheDocument();
54+
});
55+
56+
it('does not show clear button when search term is empty', () => {
57+
render(<SearchInput {...defaultProps} searchTerm="" />);
58+
59+
// Should not find X icon (clear button)
60+
expect(screen.queryByTestId('x-icon')).not.toBeInTheDocument();
61+
});
62+
63+
it('calls onSearchChange with empty string when clear button is clicked', () => {
64+
const onSearchChange = vi.fn();
65+
render(<SearchInput {...defaultProps} searchTerm="some text" onSearchChange={onSearchChange} />);
66+
67+
// Find the clear button (X icon)
68+
const clearButton = screen.getByTestId('x-icon');
69+
expect(clearButton).toBeInTheDocument();
70+
71+
const clearButtonContainer = clearButton.parentElement;
72+
if (!clearButtonContainer) {
73+
throw new Error('Expected clear button parent element to exist');
74+
}
75+
76+
fireEvent.click(clearButtonContainer);
77+
expect(onSearchChange).toHaveBeenCalledWith('');
78+
});
79+
80+
it('has proper accessibility attributes', () => {
81+
render(<SearchInput {...defaultProps} />);
82+
83+
const input = screen.getByRole('textbox');
84+
expect(input).toHaveAttribute('aria-label');
85+
});
86+
87+
it('handles multiple input changes correctly', () => {
88+
const onSearchChange = vi.fn();
89+
render(<SearchInput {...defaultProps} onSearchChange={onSearchChange} />);
90+
91+
const input = screen.getByRole('textbox') as HTMLInputElement;
92+
93+
input.value = 'first';
94+
fireEvent.change(input);
95+
96+
input.value = 'second';
97+
fireEvent.change(input);
98+
99+
input.value = 'third';
100+
fireEvent.change(input);
101+
102+
expect(onSearchChange).toHaveBeenCalledTimes(3);
103+
expect(onSearchChange).toHaveBeenNthCalledWith(1, 'first');
104+
expect(onSearchChange).toHaveBeenNthCalledWith(2, 'second');
105+
expect(onSearchChange).toHaveBeenNthCalledWith(3, 'third');
106+
});
107+
});

frontend/src/components/device/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,7 @@ export interface DeviceListState {
3131
editNote: string,
3232
editChargerIdx: number,
3333
sortColumn: SortColumn,
34-
sortSequence: "asc" | "desc"
34+
sortSequence: "asc" | "desc",
35+
searchTerm: string,
36+
filteredDevices: StateDevice[]
3537
}

frontend/src/locales/de.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ export const de ={
9494
"loading_webinterface": "Webinterface wird geladen",
9595
"invalid_key": "Die auf dem Gerät gespeicherten Schlüssel scheinen fehlerhaft zu sein. Sollte der Fehler weiterhin bestehen, entferne das Gerät und füge es erneut hinzu.",
9696
"connection_timeout": "Timeout",
97-
"connection_timeout_text": "Ein Timeout ist während des Aufbaus der Verbindung aufgetreten. Bitte versuche es später erneut oder kontaktiere uns falls das Problem bestehen bleibt."
97+
"connection_timeout_text": "Ein Timeout ist während des Aufbaus der Verbindung aufgetreten. Bitte versuche es später erneut oder kontaktiere uns falls das Problem bestehen bleibt.",
98+
"search_devices": "Geräte suchen",
99+
"search_devices_placeholder": "Suche nach Name, ID, Notiz oder Status...",
100+
"clear_search": "Suche löschen",
101+
"no_devices_found": "Keine Geräte gefunden, die deiner Suche entsprechen."
98102
},
99103
"navbar": {
100104
"home": "Home",

frontend/src/locales/en.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ export const en = {
9494
"loading_webinterface": "Loading webinterface",
9595
"invalid_key": "The keys saved on the device seem to be corrupted. If the error persists, remove the device and add it again.",
9696
"connection_timeout": "Timeout",
97-
"connection_timeout_text": "A timeout occured while establishing the connection. Please try again later or contact us in case the problem persists."
97+
"connection_timeout_text": "A timeout occured while establishing the connection. Please try again later or contact us in case the problem persists.",
98+
"search_devices": "Search devices",
99+
"search_devices_placeholder": "Search by name, ID, note, or status...",
100+
"clear_search": "Clear search",
101+
"no_devices_found": "No devices found matching your search."
98102
},
99103
"navbar": {
100104
"home": "Home",

frontend/src/pages/Devices.tsx

Lines changed: 75 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DeviceTable } from "../components/device/DeviceTable";
1313
import { DeviceMobileView } from "../components/device/DeviceMobileView";
1414
import { DeleteDeviceModal } from "../components/device/DeleteDeviceModal";
1515
import { EditNoteModal } from "../components/device/EditNoteModal";
16+
import { SearchInput } from "../components/device/SearchInput";
1617

1718
export class DeviceList extends Component<{}, DeviceListState> {
1819
removalDevice: StateDevice;
@@ -40,6 +41,8 @@ export class DeviceList extends Component<{}, DeviceListState> {
4041
editChargerIdx: 0,
4142
sortColumn: "none",
4243
sortSequence: "asc",
44+
searchTerm: "",
45+
filteredDevices: [],
4346
};
4447

4548
this.updateChargers();
@@ -223,6 +226,24 @@ export class DeviceList extends Component<{}, DeviceListState> {
223226
});
224227
}
225228

229+
filterDevices(devices: StateDevice[], searchTerm: string): StateDevice[] {
230+
if (!searchTerm.trim()) {
231+
return devices;
232+
}
233+
234+
const lowerSearchTerm = searchTerm.toLowerCase().trim();
235+
return devices.filter(device => {
236+
return (
237+
device.name.toLowerCase().includes(lowerSearchTerm) ||
238+
device.id.toLowerCase().includes(lowerSearchTerm) ||
239+
device.uid.toString().includes(lowerSearchTerm) ||
240+
device.status.toLowerCase().includes(lowerSearchTerm) ||
241+
device.note.toLowerCase().includes(lowerSearchTerm) ||
242+
device.firmware_version.toLowerCase().includes(lowerSearchTerm)
243+
);
244+
});
245+
}
246+
226247
setSortedDevices(devices: StateDevice[]) {
227248
devices.sort((a, b) => {
228249
let sortColumn = this.state.sortColumn;
@@ -256,7 +277,9 @@ export class DeviceList extends Component<{}, DeviceListState> {
256277
return ret * -1;
257278

258279
});
259-
this.setState({ devices });
280+
281+
const filteredDevices = this.filterDevices(devices, this.state.searchTerm);
282+
this.setState({ devices, filteredDevices });
260283
}
261284

262285
handleDelete = (device: StateDevice) => {
@@ -311,17 +334,22 @@ export class DeviceList extends Component<{}, DeviceListState> {
311334
});
312335
}
313336

337+
handleSearchChange = (searchTerm: string) => {
338+
const filteredDevices = this.filterDevices(this.state.devices, searchTerm);
339+
this.setState({ searchTerm, filteredDevices });
340+
}
341+
314342
render() {
315343
const { t } = useTranslation("", { useSuspense: false, keyPrefix: "chargers" });
316344
const { route } = useLocation();
317-
const devices = this.state.devices;
345+
const devices = this.state.filteredDevices.length > 0 || this.state.searchTerm ? this.state.filteredDevices : this.state.devices;
318346

319347
const handleConnect = async (device: StateDevice) => {
320348
await this.connect_to_charger(device, route);
321349
};
322350

323-
// Show empty state message if no devices
324-
if (devices.length === 0) {
351+
// Show empty state message if no devices at all
352+
if (this.state.devices.length === 0) {
325353
return (
326354
<Container fluid className="text-center mt-5">
327355
<div className="text-muted">
@@ -348,33 +376,50 @@ export class DeviceList extends Component<{}, DeviceListState> {
348376
onCancel={this.handleEditNoteCancel}
349377
/>
350378

351-
<DeviceTable
352-
devices={devices}
353-
sortColumn={this.state.sortColumn}
354-
sortSequence={this.state.sortSequence}
355-
onSort={(column) => this.setSort(column)}
356-
onConnect={handleConnect}
357-
onDelete={this.handleDelete}
358-
onEditNote={this.handleEditNote}
359-
connectionPossible={(device) => this.connection_possible(device)}
360-
formatLastStateChange={(t, timestamp) => this.formatLastStateChange(t, timestamp)}
361-
/>
379+
<Container fluid className="mb-3">
380+
<SearchInput
381+
searchTerm={this.state.searchTerm}
382+
onSearchChange={this.handleSearchChange}
383+
/>
384+
</Container>
362385

363-
<DeviceMobileView
364-
devices={devices}
365-
sortColumn={this.state.sortColumn}
366-
sortSequence={this.state.sortSequence}
367-
onMobileSort={(column) => this.setMobileSort(column)}
368-
onSortSequenceChange={(sequence) => this.setState({ sortSequence: sequence }, () => {
369-
// Re-sort the devices after state update
370-
this.setSortedDevices([...this.state.devices]);
371-
})}
372-
onConnect={handleConnect}
373-
onDelete={this.handleDelete}
374-
onEditNote={this.handleEditNote}
375-
connectionPossible={(device) => this.connection_possible(device)}
376-
formatLastStateChange={(t, timestamp) => this.formatLastStateChange(t, timestamp)}
377-
/>
386+
{devices.length === 0 && this.state.searchTerm ? (
387+
<Container fluid className="text-center mt-5">
388+
<div className="text-muted">
389+
<h5>{t("no_devices_found")}</h5>
390+
</div>
391+
</Container>
392+
) : (
393+
<>
394+
<DeviceTable
395+
devices={devices}
396+
sortColumn={this.state.sortColumn}
397+
sortSequence={this.state.sortSequence}
398+
onSort={(column) => this.setSort(column)}
399+
onConnect={handleConnect}
400+
onDelete={this.handleDelete}
401+
onEditNote={this.handleEditNote}
402+
connectionPossible={(device) => this.connection_possible(device)}
403+
formatLastStateChange={(t, timestamp) => this.formatLastStateChange(t, timestamp)}
404+
/>
405+
406+
<DeviceMobileView
407+
devices={devices}
408+
sortColumn={this.state.sortColumn}
409+
sortSequence={this.state.sortSequence}
410+
onMobileSort={(column) => this.setMobileSort(column)}
411+
onSortSequenceChange={(sequence) => this.setState({ sortSequence: sequence }, () => {
412+
// Re-sort the devices after state update
413+
this.setSortedDevices([...this.state.devices]);
414+
})}
415+
onConnect={handleConnect}
416+
onDelete={this.handleDelete}
417+
onEditNote={this.handleEditNote}
418+
connectionPossible={(device) => this.connection_possible(device)}
419+
formatLastStateChange={(t, timestamp) => this.formatLastStateChange(t, timestamp)}
420+
/>
421+
</>
422+
)}
378423
</>
379424
);
380425
}

0 commit comments

Comments
 (0)