Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ public ResponseEntity<List<Capture>> 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<Capture> page = captureService.getCaptures(limit, cursor, converted, deleted);
CursorPage<Capture> page = captureService.getCaptures(limit, cursor, converted, deleted, q);
ResponseEntity.BodyBuilder response = ResponseEntity.ok();
if (page.nextCursor() != null) {
response.header("X-Next-Cursor", page.nextCursor());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ public ResponseEntity<List<Item>> 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<Item> page = itemService.getItems(limit, cursor, status, type, deleted);
CursorPage<Item> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,22 @@ public Capture createCapture(String rawContent) {
return saved;
}

public CursorPage<Capture> getCaptures(Integer limit, String cursor, Boolean converted, Boolean deleted) {
public CursorPage<Capture> getCaptures(
Integer limit,
String cursor,
Boolean converted,
Boolean deleted,
String query
) {
String normalizedQuery = normalizeQuery(query);
List<Capture> 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();
Expand Down Expand Up @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,23 @@ public ItemService(
/**
* Returns items sorted by newest first to match current UI expectation.
*/
public CursorPage<Item> getItems(Integer limit, String cursor, ItemStatus status, ItemType type, Boolean deleted) {
public CursorPage<Item> getItems(
Integer limit,
String cursor,
ItemStatus status,
ItemType type,
Boolean deleted,
String query
) {
String normalizedQuery = normalizeQuery(query);
List<Item> 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();
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/workspaceCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ type WorkspaceCopy = {
filterStatusTodo: string
filterStatusActive: string
filterTypeAll: string
searchCapturesPlaceholder: string
searchItemsPlaceholder: string
projectPulseTitle: string
projectPulseSubtitle: string
projectPulseShow: string
Expand Down Expand Up @@ -111,6 +113,8 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
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',
Expand Down Expand Up @@ -190,6 +194,8 @@ export const workspaceCopy: Record<Locale, WorkspaceCopy> = {
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',
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/pages/WorkspacePage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -893,6 +909,10 @@
align-items: flex-start;
justify-content: flex-start;
}

.workspace-page .toolbar-search-input {
min-width: 100%;
}
}

@media (max-width: 680px) {
Expand Down
Loading
Loading