From 7f80d661bd607b7daf14e4b03dfeb307d4016324 Mon Sep 17 00:00:00 2001 From: kushal-saha Date: Tue, 5 May 2026 09:15:22 +0000 Subject: [PATCH] feature: add chat search - first check if the chat is available locally, if not then hit the server --- .../app/Actions/Chats/GetUserChatsAction.php | 10 ++- .../Http/Controllers/Chats/ChatController.php | 6 +- frontend/src/app/app.ts | 4 +- frontend/src/app/chat/chat-service.ts | 4 ++ frontend/src/app/chat/chat.store.ts | 61 +++++++++++++++++-- frontend/src/app/chat/chat.types.ts | 5 +- .../src/app/core/layout/sidebar/sidebar.html | 6 +- .../src/app/core/layout/sidebar/sidebar.ts | 6 +- 8 files changed, 78 insertions(+), 24 deletions(-) diff --git a/backend/app/Actions/Chats/GetUserChatsAction.php b/backend/app/Actions/Chats/GetUserChatsAction.php index 73bb134..8916e53 100644 --- a/backend/app/Actions/Chats/GetUserChatsAction.php +++ b/backend/app/Actions/Chats/GetUserChatsAction.php @@ -9,10 +9,14 @@ final readonly class GetUserChatsAction { - public function chats(User $user): LengthAwarePaginator|AbstractPaginator + public function chats(User $user, ?string $searchKeyword = null): LengthAwarePaginator|AbstractPaginator { - $chats = $user->chats()->latest()->paginate(); + $chats = $user->chats()->getQuery(); + if ($searchKeyword) { + $chats = $chats->where('title', 'LIKE', "%{$searchKeyword}%"); + } + $results = $chats->latest()->paginate(); - return $chats->through(fn ($chat) => ChatResponseDto::fromModel($chat)); + return $results->through(fn ($chat) => ChatResponseDto::fromModel($chat)); } } diff --git a/backend/app/Http/Controllers/Chats/ChatController.php b/backend/app/Http/Controllers/Chats/ChatController.php index 952352d..fa847b2 100644 --- a/backend/app/Http/Controllers/Chats/ChatController.php +++ b/backend/app/Http/Controllers/Chats/ChatController.php @@ -15,13 +15,11 @@ class ChatController extends Controller { public function index(Request $request, GetUserChatsAction $getUserChatsAction) { - return ChatResponseResource::collection($getUserChatsAction->chats($request->user())); + return ChatResponseResource::collection($getUserChatsAction->chats($request->user(), $request->input('search', null))); } public function store(CreateChatRequest $request, CreateChatAction $createChatAction) { - return new ChatResponseResource( - $createChatAction->create($request->user(), $request->input('title', 'New Chat')) - ); + return new ChatResponseResource($createChatAction->create($request->user(), $request->input('title', 'New Chat'))); } } diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 078801e..7452d81 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,11 +1,9 @@ import { Component, signal } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { Header } from './core/layout/header/header'; -import { Sidebar } from './core/layout/sidebar/sidebar'; @Component({ selector: 'app-root', - imports: [RouterOutlet, Header, Sidebar], + imports: [RouterOutlet], templateUrl: './app.html', styleUrl: './app.css', }) diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts index 76fc32a..aa5e8d1 100644 --- a/frontend/src/app/chat/chat-service.ts +++ b/frontend/src/app/chat/chat-service.ts @@ -27,4 +27,8 @@ export class ChatService { public getChats() { return this.http.get(`${this.apiUrl}/chats`); } + + public searchChat(keyword: string) { + return this.http.get(`${this.apiUrl}/chats?search=${keyword}`); + } } diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index 9779d72..a781b02 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -1,12 +1,20 @@ -import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; -import { inject } from '@angular/core'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { computed, inject } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { ChatService } from './chat-service'; -import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from './chat.types'; -import { lastValueFrom } from 'rxjs'; +import { + MessageState, + Message, + MessageResponse, + ChatResponse, + ChatState, + Chat, +} from './chat.types'; +import { debounceTime, distinctUntilChanged, lastValueFrom, of, pipe, switchMap } from 'rxjs'; import { Router } from '@angular/router'; import { JsonApiResource } from '../core/types/api'; import { environment } from '../../environments/environment.development'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; const initialMessageState: MessageState = { messages: [ @@ -30,8 +38,11 @@ const initialMessageState: MessageState = { }; const initalChatState: ChatState = { - chats: [], + _chats: [], + _serverSearchResults: [], + _filteredChats: [], isLoading: true, + searchQuery: null, }; export const MessageStore = signalStore( @@ -216,6 +227,12 @@ export const MessageStore = signalStore( export const ChatStore = signalStore( { providedIn: 'root' }, withState(initalChatState), + withComputed((store) => ({ + displayedChats: computed(() => { + if (!store.searchQuery()) return store._chats(); + return store._filteredChats(); + }), + })), withMethods((store) => { const chatService = inject(ChatService); return { @@ -225,12 +242,44 @@ export const ChatStore = signalStore( const chats = await lastValueFrom(chatService.getChats()); patchState(store, { isLoading: false, - chats: chats.data, + _chats: chats.data, }); } catch (error: any) { patchState(store, { isLoading: false }); } }, + // This method first searches local chats which are loaded on init. + // If not hit, then it calls backend for results as chats come paginated. + performSearch: rxMethod( + pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(async (query) => { + patchState(store, { isLoading: true }); + + if (!query) { + patchState(store, { searchQuery: null, _filteredChats: [], isLoading: false }); + return of(null); + } + + patchState(store, { searchQuery: query }); + + const localResults = store._chats().filter((chat) => { + return chat.attributes.title?.toLowerCase().includes(query.toLowerCase()); + }); + if (localResults.length > 0) { + patchState(store, { + _filteredChats: localResults, + }); + } else { + const serverResults = await lastValueFrom(chatService.searchChat(query)); + patchState(store, { _serverSearchResults: serverResults.data }); + } + patchState(store, { isLoading: false }); + return of(null); + }), + ), + ), }; }), ); diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts index 16d7464..e8009f3 100644 --- a/frontend/src/app/chat/chat.types.ts +++ b/frontend/src/app/chat/chat.types.ts @@ -30,6 +30,9 @@ export type MessageState = { id: string | null; }; export type ChatState = { - chats: JsonApiResource[]; + _chats: JsonApiResource[]; + _serverSearchResults: JsonApiResource[]; + _filteredChats: JsonApiResource[]; isLoading: boolean; + searchQuery: string | null; }; diff --git a/frontend/src/app/core/layout/sidebar/sidebar.html b/frontend/src/app/core/layout/sidebar/sidebar.html index e2f5262..4ba1833 100644 --- a/frontend/src/app/core/layout/sidebar/sidebar.html +++ b/frontend/src/app/core/layout/sidebar/sidebar.html @@ -103,11 +103,11 @@ - @if (chatStore.chats().length !== 0) { + @if (chatStore.displayedChats().length !== 0) {
- @for (chat of chatStore.chats(); track chat.id) { + @for (chat of chatStore.displayedChats(); track chat.id) {
} - @if (chatStore.chats().length === 0) { + @if (!chatStore.isLoading() && chatStore.displayedChats().length === 0) {
(null); ngOnInit() { @@ -78,7 +76,7 @@ export class Sidebar implements OnInit { } protected onSearch(event: Event): void { - this.searchQuery.set((event.target as HTMLInputElement).value); + this.chatStore.performSearch((event.target as HTMLInputElement).value); } protected formatTime(dateValue: string | Date): string {