Feature/ implement basic search function #40
@@ -64,10 +64,11 @@ public class HomeboxSearchProvider implements SearchProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
|
List<SearchResponse> items = hbResponse.items().stream().map(i -> {
|
||||||
|
String id = i.id();
|
||||||
String title = i.name();
|
String title = i.name();
|
||||||
String description = i.description();
|
String description = i.description();
|
||||||
Map<String, Object> extraSearchResponseData = Map.of("location", i.parent());
|
Map<String, Object> extraSearchResponseData = Map.of("location", i.parent());
|
||||||
return new SearchResponse(title, description, extraSearchResponseData);
|
return new SearchResponse(id, title, description, extraSearchResponseData);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
log.info("Search results for query '{}': {}", request.query(), items);
|
log.info("Search results for query '{}': {}", request.query(), items);
|
||||||
@@ -79,7 +80,7 @@ public class HomeboxSearchProvider implements SearchProvider {
|
|||||||
List<HomeboxItem> items) {
|
List<HomeboxItem> items) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record HomeboxItem(String name, String description, HomeboxLocation parent) {
|
private record HomeboxItem(String id, String name, String description, HomeboxLocation parent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private record HomeboxLocation(String name, String description) {
|
private record HomeboxLocation(String name, String description) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package com.vaessl.app.search;
|
|||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public record SearchResponse(String title, String description, Map<String, Object> extraData) {
|
public record SearchResponse(String id, String title, String description,
|
||||||
|
Map<String, Object> extraData) {
|
||||||
|
|
||||||
public String getExtra(String key) {
|
public String getExtra(String key) {
|
||||||
if (extraData == null) {
|
if (extraData == null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public final class Mockdata {
|
|||||||
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
public static final String MOCK_SERVICE_TYPE = "SERVICE_TYPE";
|
||||||
public static final String MOCK_USER = "user";
|
public static final String MOCK_USER = "user";
|
||||||
public static final String MOCK_PASS = "pw";
|
public static final String MOCK_PASS = "pw";
|
||||||
|
public static final String MOCK_ID = "item-1";
|
||||||
public static final String MOCK_TITLE = "title";
|
public static final String MOCK_TITLE = "title";
|
||||||
public static final String MOCK_DESCRIPTION = "desc";
|
public static final String MOCK_DESCRIPTION = "desc";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class SearchResponseTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void shouldReturnNullWhenExtraDataIsNull() {
|
void shouldReturnNullWhenExtraDataIsNull() {
|
||||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, null);
|
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, null);
|
||||||
|
|
||||||
assertThat(response.getExtra(null)).isNull();
|
assertThat(response.getExtra(null)).isNull();
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@ class SearchResponseTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
void shouldReturnNullWhenExtraDataKeyIsMissing() {
|
||||||
|
|
||||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
assertThat(response.getExtra("missing")).isNull();
|
assertThat(response.getExtra("missing")).isNull();
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,9 @@ class SearchResponseTest {
|
|||||||
@Test
|
@Test
|
||||||
void shouldReturnExtraDataValue() {
|
void shouldReturnExtraDataValue() {
|
||||||
|
|
||||||
SearchResponse response = new SearchResponse(MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
SearchResponse response = new SearchResponse(MOCK_ID, MOCK_TITLE, MOCK_DESCRIPTION, Map.of("key", "value"));
|
||||||
|
|
||||||
|
assertThat(response.id()).isEqualTo(MOCK_ID);
|
||||||
assertThat(response.getExtra("key")).contains("value");
|
assertThat(response.getExtra("key")).contains("value");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { Dashboard } from './components/Dashboard'
|
import { Dashboard } from './components/connections/Dashboard'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import type { AuthResponse } from "../types/connection";
|
import type { PagedSearchResponse, SearchRequest, SearchResponse } from "../types/search";
|
||||||
import type { SearchRequest } from "../types/search";
|
|
||||||
|
|
||||||
export const search = (req: SearchRequest) =>
|
export const search = (req: SearchRequest) =>
|
||||||
apiFetch<AuthResponse>("/search", {
|
apiFetch<PagedSearchResponse<SearchResponse>>("/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify(req),
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import { login } from '../api/connections'
|
import { login } from '../../api/connections'
|
||||||
import type { LoginRequest } from '../types/connection'
|
import type { LoginRequest } from '../../types/connection'
|
||||||
import './ConnectModal.scss'
|
import '../ui/Modal.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceType: string
|
serviceType: string
|
||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { getStatuses, logout } from "../api/connections"
|
import { getStatuses, logout } from "../../api/connections"
|
||||||
import type { ConnectionStatus } from "../types/connection"
|
import type { ConnectionStatus } from "../../types/connection"
|
||||||
import { ServiceCard } from "./ServiceCard"
|
import { ServiceCard } from "./ServiceCard"
|
||||||
import { ConnectModal } from "./ConnectModal"
|
import { ConnectModal } from "./ConnectModal"
|
||||||
import "./Dashboard.scss"
|
import "./Dashboard.scss"
|
||||||
import { SearchModal } from "./SearchModal"
|
import { SearchModal } from "../search/SearchModal"
|
||||||
|
|
||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' },
|
{ serviceType: 'HOMEBOX', label: 'Homebox', icon: '📦' },
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { ConnectionStatus } from '../types/connection'
|
import type { ConnectionStatus } from '../../types/connection'
|
||||||
import { ActionButton } from './ActionButton'
|
import { ActionButton } from '../ui/ActionButton'
|
||||||
import './ServiceCard.scss'
|
import './ServiceCard.scss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
+28
-14
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
import { useEffect, useRef, useState, type SyntheticEvent } from 'react'
|
||||||
import './ConnectModal.scss'
|
import '../ui/Modal.scss'
|
||||||
import type { SearchRequest } from '../types/search'
|
import { type PagedSearchResponse, type SearchResponse, type SearchRequest } from '../../types/search'
|
||||||
import { search } from '../api/searches'
|
import { search } from '../../api/searches'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceType: string
|
serviceType: string
|
||||||
@@ -12,7 +12,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly<Props>) {
|
export function SearchModal({ serviceType, label, appUrl, username, onClose }: Readonly<Props>) {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<PagedSearchResponse<SearchResponse> | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -40,7 +41,10 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const req: SearchRequest = { appUrl, serviceType, username, query}
|
const req: SearchRequest = { appUrl, serviceType, username, query}
|
||||||
await search(req)
|
const res = await search(req)
|
||||||
|
console.log(req)
|
||||||
|
setResults(res)
|
||||||
|
console.log('results', results)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Search failed')
|
setError(err instanceof Error ? err.message : 'Search failed')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,21 +53,31 @@ export function SearchModal({ serviceType, label, appUrl, username, onClose }: R
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog className="modal" ref={dialogRef}>
|
<dialog className='modal' ref={dialogRef}>
|
||||||
<div className="modal__header">
|
<div className='modal__header'>
|
||||||
<h2 className="modal__title" id="modal-title">Search in {label}</h2>
|
<h2 className='modal__title' id='modal-title'>Search in {label}</h2>
|
||||||
<button className="modal__close" onClick={onClose} aria-label="Close">×</button>
|
<button className='modal__close' onClick={onClose} aria-label='Close'>×</button>
|
||||||
</div>
|
</div>
|
||||||
<form className="modal__form" onSubmit={handleSubmit}>
|
<form className='modal__form' onSubmit={handleSubmit}>
|
||||||
<div className="modal__field">
|
<div className='modal__field'>
|
||||||
<input id="search" className="modal__input"
|
<input id='search' className='modal__input'
|
||||||
value={query} onChange={e => setQuery(e.target.value)} />
|
value={query} onChange={e => setQuery(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="modal__error">{error}</p>}
|
{error && <p className='modal__error'>{error}</p>}
|
||||||
<button className="modal__submit" type="submit" disabled={loading}>
|
<button className='modal__submit' type='submit' disabled={loading}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{results && (
|
||||||
|
<div className='modal__results'>
|
||||||
|
<p className='modal__results-count'>{results.totalElements}</p>
|
||||||
|
{results.content.map((item) => (<div key={item.id} className='modal__result-item'>
|
||||||
|
<p className='"modal__result-title'>{item.title}</p>
|
||||||
|
{item.description && <p className='modal__result-desc'>{item.description}</p>}
|
||||||
|
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</dialog>
|
</dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -87,7 +89,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(11, 0, 0, 0.499);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
}
|
}
|
||||||
@@ -110,4 +112,44 @@
|
|||||||
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
&:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
&:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--expanded {
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__results {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0; // required: flex children won't shrink without this
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__results-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-title {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-h);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,22 @@ export interface SearchRequest {
|
|||||||
appUrl: string;
|
appUrl: string;
|
||||||
serviceType: string;
|
serviceType: string;
|
||||||
username: string;
|
username: string;
|
||||||
query: string;
|
query: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
extraData: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedSearchResponse<T> {
|
||||||
|
content: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalElements: number;
|
||||||
|
first: boolean;
|
||||||
|
last: boolean;
|
||||||
|
sort: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user