From a12764c802f238d999ae3f8f51b815739b8ebfc7 Mon Sep 17 00:00:00 2001 From: Hammond Date: Fri, 22 May 2026 14:07:49 +0700 Subject: [PATCH] feat(workspace): add debounced content search for captures and items --- .../backend/controller/CaptureController.java | 5 +- .../backend/controller/ItemController.java | 5 +- .../backend/service/CaptureService.java | 19 +- .../opspace/backend/service/ItemService.java | 20 +- .../CaptureControllerIntegrationTests.java | 44 ++++ frontend/src/i18n/workspaceCopy.ts | 6 + frontend/src/pages/WorkspacePage.css | 20 ++ frontend/src/pages/WorkspacePage.tsx | 198 +++++++++++------- frontend/src/services/api.ts | 4 + 9 files changed, 235 insertions(+), 86 deletions(-) diff --git a/backend/src/main/java/io/opspace/backend/controller/CaptureController.java b/backend/src/main/java/io/opspace/backend/controller/CaptureController.java index 504d39b..7c8ba85 100644 --- a/backend/src/main/java/io/opspace/backend/controller/CaptureController.java +++ b/backend/src/main/java/io/opspace/backend/controller/CaptureController.java @@ -43,9 +43,10 @@ public ResponseEntity> getCaptures( @RequestParam(required = false) Integer limit, @RequestParam(required = false) String cursor, @RequestParam(required = false) Boolean converted, - @RequestParam(required = false) Boolean deleted + @RequestParam(required = false) Boolean deleted, + @RequestParam(required = false) String q ) { - CursorPage page = captureService.getCaptures(limit, cursor, converted, deleted); + CursorPage page = captureService.getCaptures(limit, cursor, converted, deleted, q); ResponseEntity.BodyBuilder response = ResponseEntity.ok(); if (page.nextCursor() != null) { response.header("X-Next-Cursor", page.nextCursor()); diff --git a/backend/src/main/java/io/opspace/backend/controller/ItemController.java b/backend/src/main/java/io/opspace/backend/controller/ItemController.java index ff4850e..5119e32 100644 --- a/backend/src/main/java/io/opspace/backend/controller/ItemController.java +++ b/backend/src/main/java/io/opspace/backend/controller/ItemController.java @@ -39,9 +39,10 @@ public ResponseEntity> getItems( @RequestParam(required = false) String cursor, @RequestParam(required = false) ItemStatus status, @RequestParam(required = false) ItemType type, - @RequestParam(required = false) Boolean deleted + @RequestParam(required = false) Boolean deleted, + @RequestParam(required = false) String q ) { - CursorPage page = itemService.getItems(limit, cursor, status, type, deleted); + CursorPage page = itemService.getItems(limit, cursor, status, type, deleted, q); ResponseEntity.BodyBuilder response = ResponseEntity.ok(); if (page.nextCursor() != null) { response.header("X-Next-Cursor", page.nextCursor()); diff --git a/backend/src/main/java/io/opspace/backend/service/CaptureService.java b/backend/src/main/java/io/opspace/backend/service/CaptureService.java index 1dca569..0f5472f 100644 --- a/backend/src/main/java/io/opspace/backend/service/CaptureService.java +++ b/backend/src/main/java/io/opspace/backend/service/CaptureService.java @@ -59,13 +59,22 @@ public Capture createCapture(String rawContent) { return saved; } - public CursorPage getCaptures(Integer limit, String cursor, Boolean converted, Boolean deleted) { + public CursorPage getCaptures( + Integer limit, + String cursor, + Boolean converted, + Boolean deleted, + String query + ) { + String normalizedQuery = normalizeQuery(query); List filtered = captureRepository.findAllNewestFirst().stream() .filter(capture -> deleted == null ? capture.deletedAt() == null : deleted.equals(capture.deletedAt() != null)) .filter(capture -> converted == null || (converted ? capture.convertedItemId() != null : capture.convertedItemId() == null)) + .filter(capture -> normalizedQuery == null + || capture.content().toLowerCase().contains(normalizedQuery)) .sorted(Comparator.comparing(Capture::createdAt).reversed() .thenComparing(Capture::id, Comparator.reverseOrder())) .toList(); @@ -338,6 +347,14 @@ private ItemStatus resolveDefaultStatus(ItemType type) { }; } + private String normalizeQuery(String query) { + if (query == null) { + return null; + } + String normalized = query.trim().toLowerCase(); + return normalized.isEmpty() ? null : normalized; + } + private Capture findCaptureOrThrow(String captureId) { return captureRepository.findById(captureId) .orElseThrow(() -> new NotFoundException("Capture not found")); diff --git a/backend/src/main/java/io/opspace/backend/service/ItemService.java b/backend/src/main/java/io/opspace/backend/service/ItemService.java index 613d635..0785c83 100644 --- a/backend/src/main/java/io/opspace/backend/service/ItemService.java +++ b/backend/src/main/java/io/opspace/backend/service/ItemService.java @@ -40,13 +40,23 @@ public ItemService( /** * Returns items sorted by newest first to match current UI expectation. */ - public CursorPage getItems(Integer limit, String cursor, ItemStatus status, ItemType type, Boolean deleted) { + public CursorPage getItems( + Integer limit, + String cursor, + ItemStatus status, + ItemType type, + Boolean deleted, + String query + ) { + String normalizedQuery = normalizeQuery(query); List filtered = itemRepository.findAllNewestFirst().stream() .filter(item -> deleted == null ? item.deletedAt() == null : deleted.equals(item.deletedAt() != null)) .filter(item -> status == null || item.status() == status) .filter(item -> type == null || item.type() == type) + .filter(item -> normalizedQuery == null + || item.content().toLowerCase().contains(normalizedQuery)) .sorted(Comparator.comparing(Item::createdAt).reversed() .thenComparing(Item::id, Comparator.reverseOrder())) .toList(); @@ -240,4 +250,12 @@ private String normalizeContent(String rawContent) { } return trimmed; } + + private String normalizeQuery(String query) { + if (query == null) { + return null; + } + String normalized = query.trim().toLowerCase(); + return normalized.isEmpty() ? null : normalized; + } } diff --git a/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java b/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java index 82374af..19b9c84 100644 --- a/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java +++ b/backend/src/test/java/io/opspace/backend/capture/CaptureControllerIntegrationTests.java @@ -161,6 +161,22 @@ void getCapturesRejectsInvalidPaginationInputs() throws Exception { .andExpect(jsonPath("$.error").value("Invalid cursor")); } + @Test + void getCapturesSupportsQueryFilter() throws Exception { + String productCaptureId = createCaptureAndReturnId("Ship product roadmap"); + String randomCaptureId = createCaptureAndReturnId("Buy coffee beans"); + + MvcResult result = mockMvc.perform(get("/api/v1/captures") + .param("q", " PRODUCT ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andReturn(); + + JsonNode captures = objectMapper.readTree(result.getResponse().getContentAsString()); + assertEquals(productCaptureId, captures.get(0).get("id").asText()); + assertNotEquals(randomCaptureId, captures.get(0).get("id").asText()); + } + @Test void getCaptureByIdReturnsNotFoundWhenMissing() throws Exception { mockMvc.perform(get("/api/v1/captures/does-not-exist")) @@ -346,6 +362,34 @@ void getItemsRejectsInvalidPaginationInputs() throws Exception { .andExpect(jsonPath("$.error").value("Invalid cursor")); } + @Test + void getItemsSupportsQueryFilter() throws Exception { + String taskCaptureId = createCaptureAndReturnId("Prepare launch checklist"); + mockMvc.perform(post("/api/v1/captures/{captureId}/convert", taskCaptureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"TASK"} + """)) + .andExpect(status().isCreated()); + + String noteCaptureId = createCaptureAndReturnId("Write random notes"); + mockMvc.perform(post("/api/v1/captures/{captureId}/convert", noteCaptureId) + .contentType(APPLICATION_JSON) + .content(""" + {"type":"NOTE"} + """)) + .andExpect(status().isCreated()); + + MvcResult result = mockMvc.perform(get("/api/v1/items") + .param("q", "launch")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andReturn(); + + JsonNode items = objectMapper.readTree(result.getResponse().getContentAsString()); + assertEquals("Prepare launch checklist", items.get(0).get("content").asText()); + } + @Test void getItemByIdReturnsConvertedItem() throws Exception { String captureId = createCaptureAndReturnId("Find me by id"); diff --git a/frontend/src/i18n/workspaceCopy.ts b/frontend/src/i18n/workspaceCopy.ts index 2535356..b55abfd 100644 --- a/frontend/src/i18n/workspaceCopy.ts +++ b/frontend/src/i18n/workspaceCopy.ts @@ -31,6 +31,8 @@ type WorkspaceCopy = { filterStatusTodo: string filterStatusActive: string filterTypeAll: string + searchCapturesPlaceholder: string + searchItemsPlaceholder: string projectPulseTitle: string projectPulseSubtitle: string projectPulseShow: string @@ -111,6 +113,8 @@ export const workspaceCopy: Record = { filterStatusTodo: 'To Do', filterStatusActive: 'Active', filterTypeAll: 'All Types', + searchCapturesPlaceholder: 'Search captures...', + searchItemsPlaceholder: 'Search items...', projectPulseTitle: 'Project Pulse', projectPulseSubtitle: 'A compact board view from your latest captures and items.', projectPulseShow: 'Show Board', @@ -190,6 +194,8 @@ export const workspaceCopy: Record = { filterStatusTodo: 'Cần làm', filterStatusActive: 'Đang hoạt động', filterTypeAll: 'Mọi loại', + searchCapturesPlaceholder: 'Tìm capture...', + searchItemsPlaceholder: 'Tìm item...', projectPulseTitle: 'Project Pulse', projectPulseSubtitle: 'Bảng rút gọn từ capture và item mới nhất của bạn.', projectPulseShow: 'Hiện Board', diff --git a/frontend/src/pages/WorkspacePage.css b/frontend/src/pages/WorkspacePage.css index 7b5e4f9..edde144 100644 --- a/frontend/src/pages/WorkspacePage.css +++ b/frontend/src/pages/WorkspacePage.css @@ -374,6 +374,7 @@ .workspace-page .language-option:focus-visible, .workspace-page .theme-trigger:focus-visible, .workspace-page .theme-option:focus-visible, +.workspace-page .toolbar-search-input:focus-visible, .workspace-page .back-link:focus-visible, .workspace-page .secondary:focus-visible { outline: 2px solid var(--link); @@ -600,6 +601,21 @@ flex-wrap: wrap; } +.workspace-page .toolbar-search-stack { + display: grid; + gap: 8px; +} + +.workspace-page .toolbar-search-input { + border: 1px solid var(--chip-border); + background: var(--input); + color: var(--text); + border-radius: 9px; + padding: 7px 10px; + font-size: 12px; + min-width: 180px; +} + .workspace-page .toolbar-chips-stack { flex-direction: column; align-items: flex-end; @@ -893,6 +909,10 @@ align-items: flex-start; justify-content: flex-start; } + + .workspace-page .toolbar-search-input { + min-width: 100%; + } } @media (max-width: 680px) { diff --git a/frontend/src/pages/WorkspacePage.tsx b/frontend/src/pages/WorkspacePage.tsx index 20daad7..d520407 100644 --- a/frontend/src/pages/WorkspacePage.tsx +++ b/frontend/src/pages/WorkspacePage.tsx @@ -68,6 +68,10 @@ function WorkspacePage() { const [captureFilter, setCaptureFilter] = useState('all') const [itemStatusFilter, setItemStatusFilter] = useState('ALL') const [itemTypeFilter, setItemTypeFilter] = useState('ALL') + const [captureSearchInput, setCaptureSearchInput] = useState('') + const [itemSearchInput, setItemSearchInput] = useState('') + const [captureSearchQuery, setCaptureSearchQuery] = useState('') + const [itemSearchQuery, setItemSearchQuery] = useState('') const [highlightedCaptureId, setHighlightedCaptureId] = useState(null) const [focusedCapture, setFocusedCapture] = useState(null) const [captureLoadError, setCaptureLoadError] = useState(null) @@ -96,6 +100,20 @@ function WorkspacePage() { const typeQuery = itemTypeFilter === 'ALL' ? undefined : itemTypeFilter const itemById = new Map(items.map((item) => [item.id, item] as const)) + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setCaptureSearchQuery(captureSearchInput.trim()) + }, 250) + return () => window.clearTimeout(timeoutId) + }, [captureSearchInput]) + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setItemSearchQuery(itemSearchInput.trim()) + }, 250) + return () => window.clearTimeout(timeoutId) + }, [itemSearchInput]) + const matchesItemFilter = (item: Item) => (statusQuery === undefined || item.status === statusQuery) && (typeQuery === undefined || item.type === typeQuery) @@ -140,8 +158,8 @@ function WorkspacePage() { setCaptureLoadError(null) try { const [capturePage, itemPage] = await Promise.all([ - listCaptures({ limit: PAGE_SIZE, converted: convertedQuery }), - listItems({ limit: PAGE_SIZE, status: statusQuery, type: typeQuery }), + listCaptures({ limit: PAGE_SIZE, converted: convertedQuery, q: captureSearchQuery }), + listItems({ limit: PAGE_SIZE, status: statusQuery, type: typeQuery, q: itemSearchQuery }), ]) setCaptures(capturePage.data) setItems(itemPage.data) @@ -169,7 +187,7 @@ function WorkspacePage() { } void loadData() - }, [convertedQuery, statusQuery, typeQuery, ui.failedToLoad]) + }, [convertedQuery, statusQuery, typeQuery, captureSearchQuery, itemSearchQuery, ui.failedToLoad]) // Poll captures periodically so UI reflects updates made elsewhere. const pollRef = useRef(null) @@ -182,7 +200,7 @@ function WorkspacePage() { if (document.hidden) return // don't poll when tab is not visible if (isSubmittingCapture) return // avoid clobbering optimistic submit try { - const latest = await listCaptures({ limit: PAGE_SIZE, converted: convertedQuery }) + const latest = await listCaptures({ limit: PAGE_SIZE, converted: convertedQuery, q: captureSearchQuery }) // Replace captures with latest from server setCaptures(latest.data) setCaptureCursor(latest.nextCursor) @@ -202,7 +220,7 @@ function WorkspacePage() { pollRef.current = null } } - }, [convertedQuery, isSubmittingCapture]) + }, [convertedQuery, captureSearchQuery, isSubmittingCapture]) const refreshDeletedData = async () => { try { @@ -596,6 +614,7 @@ function WorkspacePage() { limit: PAGE_SIZE, cursor: captureCursor, converted: convertedQuery, + q: captureSearchQuery, }) setCaptures((current) => [...current, ...page.data]) setCaptureCursor(page.nextCursor) @@ -617,6 +636,7 @@ function WorkspacePage() { cursor: itemCursor, status: statusQuery, type: typeQuery, + q: itemSearchQuery, }) setItems((current) => [...current, ...page.data]) setItemCursor(page.nextCursor) @@ -839,28 +859,37 @@ function WorkspacePage() { localeCode={ui.localeCode} highlightedCaptureId={highlightedCaptureId} headerActions={ -
- - - +
+ setCaptureSearchInput(event.target.value)} + placeholder={ui.searchCapturesPlaceholder} + aria-label="search-captures" + className="toolbar-search-input" + /> +
+ + + +
} footerActions={ @@ -899,59 +928,68 @@ function WorkspacePage() { deletingItemId={deletingItemId} editingItemId={editingItemId} headerActions={ -
-
- - - -
-
- - - - +
+ setItemSearchInput(event.target.value)} + placeholder={ui.searchItemsPlaceholder} + aria-label="search-items" + className="toolbar-search-input" + /> +
+
+ + + +
+
+ + + + +
} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e3f4890..06ca4d7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -15,12 +15,14 @@ type CursorQuery = { export type CaptureListQuery = CursorQuery & { converted?: boolean deleted?: boolean + q?: string } export type ItemListQuery = CursorQuery & { status?: ItemStatus type?: ItemType deleted?: boolean + q?: string } type ApiErrorPayload = { @@ -83,6 +85,7 @@ export async function listCaptures(query: CaptureListQuery = {}): Promise(`${API_BASE}/captures${queryString}`) } @@ -103,6 +106,7 @@ export async function listItems(query: ItemListQuery = {}): Promise(`${API_BASE}/items${queryString}`) }