From 2f89c7e13b4682c4dc140f02e44919854e9fb8a4 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 12 Nov 2025 21:06:20 +0800 Subject: [PATCH] feat(EntityGrid): add server-side search with pagination for projects Implement server-side search for projects using API endpoint with pagination support via beforeId parameter. Contacts continue using client-side filtering from complete local database. - Add PlatformServiceMixin for internal apiServer access - Implement performProjectSearch() with pagination - Update infinite scroll to handle search pagination - Add search lifecycle management and error handling No breaking changes to parent components. --- src/components/EntityGrid.vue | 313 +++++++++++++++++++++++++++------- 1 file changed, 253 insertions(+), 60 deletions(-) diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index 02de2b28..c0daf348 100644 --- a/src/components/EntityGrid.vue +++ b/src/components/EntityGrid.vue @@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts"; import { PlanData } from "../interfaces/records"; import { NotificationIface } from "../constants/app"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; +import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; +import { getHeaders } from "../libs/endorserServer"; +import { logger } from "../utils/logger"; +import { TIMEOUTS } from "@/utils/notify"; /** * Constants for infinite scroll configuration @@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3; ProjectCard, SpecialEntityCard, }, + mixins: [PlatformServiceMixin], }) export default class EntityGrid extends Vue { /** Type of entities to display */ @@ -202,6 +207,11 @@ export default class EntityGrid extends Vue { isSearching = false; searchTimeout: NodeJS.Timeout | null = null; filteredEntities: Contact[] | PlanData[] = []; + searchBeforeId: string | undefined = undefined; + isLoadingSearchMore = false; + + // API server for project searches + apiServer = ""; // Infinite scroll state displayedCount = INITIAL_BATCH_SIZE; @@ -212,14 +222,15 @@ export default class EntityGrid extends Vue { /** * Array of entities to display * + * For contacts: Must be a COMPLETE list from local database. + * Use $contactsByDateAdded() to ensure all contacts are included. + * Client-side filtering assumes the complete list is available. * IMPORTANT: When passing Contact[] arrays, they must be sorted by date added * (newest first) for the "Recently Added" section to display correctly. - * Use $contactsByDateAdded() instead of $getAllContacts() or $contacts(). * - * The recentContacts computed property assumes contacts are already sorted - * by date added and simply takes the first 3. If contacts are sorted - * alphabetically or in another order, the wrong contacts will appear in - * "Recently Added". + * For projects: Can be partial list (pagination supported). + * Server-side search will fetch matching results with pagination, + * regardless of what's in this prop. */ @Prop({ required: true }) entities!: Contact[] | PlanData[]; @@ -475,47 +486,27 @@ export default class EntityGrid extends Vue { /** * Perform the actual search + * Routes to server-side search for projects or client-side filtering for contacts */ async performSearch(): Promise { if (!this.searchTerm.trim()) { this.filteredEntities = []; this.displayedCount = INITIAL_BATCH_SIZE; + this.searchBeforeId = undefined; this.infiniteScrollReset?.(); return; } this.isSearching = true; + this.searchBeforeId = undefined; // Reset pagination for new search try { - // Simulate async search (in case we need to add API calls later) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const searchLower = this.searchTerm.toLowerCase().trim(); - - if (this.entityType === "people") { - this.filteredEntities = (this.entities as Contact[]) - .filter((contact: Contact) => { - const name = contact.name?.toLowerCase() || ""; - const did = contact.did.toLowerCase(); - return name.includes(searchLower) || did.includes(searchLower); - }) - .sort((a: Contact, b: Contact) => { - // Sort alphabetically by name, falling back to DID if name is missing - const nameA = (a.name || a.did).toLowerCase(); - const nameB = (b.name || b.did).toLowerCase(); - return nameA.localeCompare(nameB); - }); + if (this.entityType === "projects") { + // Server-side search for projects (initial load, no beforeId) + await this.performProjectSearch(); } else { - this.filteredEntities = (this.entities as PlanData[]) - .filter((project: PlanData) => { - const name = project.name?.toLowerCase() || ""; - const handleId = project.handleId.toLowerCase(); - return name.includes(searchLower) || handleId.includes(searchLower); - }) - .sort((a: PlanData, b: PlanData) => { - // Sort alphabetically by name - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }); + // Client-side filtering for contacts (complete list) + await this.performContactSearch(); } // Reset displayed count when search completes @@ -526,6 +517,148 @@ export default class EntityGrid extends Vue { } } + /** + * Perform server-side project search with optional pagination + * Uses claimContents parameter for search and beforeId for pagination. + * Results are appended when paginating, replaced on initial search. + * + * @param beforeId - Optional rowId for pagination (loads projects before this ID) + */ + async performProjectSearch(beforeId?: string): Promise { + if (!this.apiServer) { + this.filteredEntities = []; + if (this.notify) { + this.notify( + { + group: "alert", + type: "danger", + title: "Error", + text: "API server not configured", + }, + TIMEOUTS.SHORT, + ); + } + return; + } + + const searchLower = this.searchTerm.toLowerCase().trim(); + let url = `${this.apiServer}/api/v2/report/plans?claimContents=${encodeURIComponent(searchLower)}`; + + if (beforeId) { + url += `&beforeId=${encodeURIComponent(beforeId)}`; + } + + try { + const response = await fetch(url, { + method: "GET", + headers: await getHeaders(this.activeDid), + }); + + if (response.status !== 200) { + throw new Error("Failed to search projects"); + } + + const results = await response.json(); + if (results.data) { + const newProjects = results.data.map( + (plan: PlanData & { rowId?: string }) => ({ + ...plan, + rowId: plan.rowId, + }), + ); + + logger.debug("[EntityGrid] Project search results", { + beforeId, + newProjectsCount: newProjects.length, + hasRowId: + newProjects.length > 0 + ? !!newProjects[newProjects.length - 1]?.rowId + : false, + lastRowId: + newProjects.length > 0 + ? newProjects[newProjects.length - 1]?.rowId + : undefined, + }); + + if (beforeId) { + // Pagination: append new projects to existing search results + this.filteredEntities.push(...newProjects); + } else { + // Initial search: replace array + this.filteredEntities = newProjects; + } + + // Update searchBeforeId for next pagination + // Use the last project's rowId, or undefined if no more results + if (newProjects.length > 0) { + const lastProject = newProjects[newProjects.length - 1]; + // Only set searchBeforeId if rowId exists (indicates more results available) + this.searchBeforeId = lastProject.rowId || undefined; + logger.debug("[EntityGrid] Updated searchBeforeId", { + searchBeforeId: this.searchBeforeId, + filteredEntitiesCount: this.filteredEntities.length, + }); + } else { + this.searchBeforeId = undefined; // No more results + logger.debug("[EntityGrid] No more search results", { + filteredEntitiesCount: this.filteredEntities.length, + }); + } + } else { + if (!beforeId) { + // Only clear on initial search, not pagination + this.filteredEntities = []; + } + this.searchBeforeId = undefined; + } + } catch (error) { + logger.error("Error searching projects:", error); + if (!beforeId) { + // Only clear on initial search error, not pagination error + this.filteredEntities = []; + } + this.searchBeforeId = undefined; + if (this.notify) { + this.notify( + { + group: "alert", + type: "danger", + title: "Error", + text: "Failed to search projects. Please try again.", + }, + TIMEOUTS.STANDARD, + ); + } + } + } + + /** + * Client-side contact search + * Assumes entities prop contains complete contact list from local database + */ + async performContactSearch(): Promise { + // Simulate async (for consistency with project search) + await new Promise((resolve) => setTimeout(resolve, 100)); + + const searchLower = this.searchTerm.toLowerCase().trim(); + + this.filteredEntities = (this.entities as Contact[]) + .filter((contact: Contact) => { + const name = contact.name?.toLowerCase() || ""; + const did = contact.did.toLowerCase(); + return name.includes(searchLower) || did.includes(searchLower); + }) + .sort((a: Contact, b: Contact) => { + // Sort alphabetically by name, falling back to DID if name is missing + const nameA = (a.name || a.did).toLowerCase(); + const nameB = (b.name || b.did).toLowerCase(); + return nameA.localeCompare(nameB); + }); + + // Contacts don't need pagination (complete list) + this.searchBeforeId = undefined; + } + /** * Clear the search */ @@ -534,6 +667,7 @@ export default class EntityGrid extends Vue { this.filteredEntities = []; this.isSearching = false; this.displayedCount = INITIAL_BATCH_SIZE; + this.searchBeforeId = undefined; this.infiniteScrollReset?.(); // Clear any pending timeout @@ -553,13 +687,27 @@ export default class EntityGrid extends Vue { } if (this.searchTerm.trim()) { - // Search mode: check filtered entities - return this.displayedCount < this.filteredEntities.length; + // Search mode: check if more results available + if (this.entityType === "projects") { + // Projects: can load more if: + // 1. We have more already-loaded results to show, OR + // 2. We've shown all loaded results AND there's a searchBeforeId to load more + const hasMoreLoaded = + this.displayedCount < this.filteredEntities.length; + const canLoadMoreFromServer = + this.displayedCount >= this.filteredEntities.length && + !!this.searchBeforeId && + !this.isLoadingSearchMore; + return hasMoreLoaded || canLoadMoreFromServer; + } else { + // Contacts: client-side filtering returns all results at once + return this.displayedCount < this.filteredEntities.length; + } } + // Non-search mode: existing logic if (this.entityType === "projects") { - // Projects: if we've shown all loaded entities, callback handles server-side availability - // If callback exists and we've reached the end, assume more might be available + // Projects: if we've shown all loaded entities and callback exists, callback handles server-side availability if ( this.displayedCount >= this.entities.length && this.loadMoreCallback @@ -579,7 +727,13 @@ export default class EntityGrid extends Vue { /** * Initialize infinite scroll on mount */ - mounted(): void { + async mounted(): Promise { + // Load apiServer for project searches + if (this.entityType === "projects") { + const settings = await this.$accountSettings(); + this.apiServer = settings.apiServer || ""; + } + this.$nextTick(() => { const container = this.$refs.scrollContainer as HTMLElement; @@ -587,28 +741,59 @@ export default class EntityGrid extends Vue { const { reset } = useInfiniteScroll( container, async () => { - // For projects: if we've shown all entities and callback exists, call it - if ( - this.entityType === "projects" && - this.displayedCount >= this.entities.length && - this.loadMoreCallback && - !this.isLoadingMore - ) { - this.isLoadingMore = true; - try { - await this.loadMoreCallback(this.entities); - // After callback, entities prop will update via Vue reactivity - // Reset scroll state to allow further loading - this.infiniteScrollReset?.(); - } catch (error) { - // Error handling is up to the callback, but we should reset loading state - console.error("Error in loadMoreCallback:", error); - } finally { - this.isLoadingMore = false; + // Search mode: handle search pagination + if (this.searchTerm.trim()) { + if (this.entityType === "projects") { + // Projects: load more search results if available + if ( + this.displayedCount >= this.filteredEntities.length && + this.searchBeforeId && + !this.isLoadingSearchMore + ) { + this.isLoadingSearchMore = true; + try { + await this.performProjectSearch(this.searchBeforeId); + // After loading more, reset scroll state to allow further loading + this.infiniteScrollReset?.(); + } catch (error) { + logger.error("Error loading more search results:", error); + // Error already handled in performProjectSearch + } finally { + this.isLoadingSearchMore = false; + } + } else { + // Show more from already-loaded search results + this.displayedCount += INCREMENT_SIZE; + } + } else { + // Contacts: show more from already-filtered results + this.displayedCount += INCREMENT_SIZE; } } else { - // Normal case: increment displayedCount to show more from memory - this.displayedCount += INCREMENT_SIZE; + // Non-search mode: existing logic + // For projects: if we've shown all entities and callback exists, call it + if ( + this.entityType === "projects" && + this.displayedCount >= this.entities.length && + this.loadMoreCallback && + !this.isLoadingMore + ) { + this.isLoadingMore = true; + try { + await this.loadMoreCallback(this.entities); + // After callback, entities prop will update via Vue reactivity + // Reset scroll state to allow further loading + this.infiniteScrollReset?.(); + } catch (error) { + // Error handling is up to the callback, but we should reset loading state + console.error("Error in loadMoreCallback:", error); + } finally { + this.isLoadingMore = false; + } + } else { + // Normal case: increment displayedCount to show more from memory + this.displayedCount += INCREMENT_SIZE; + } } }, { @@ -635,19 +820,27 @@ export default class EntityGrid extends Vue { } /** - * Watch for changes in search term to reset displayed count + * Watch for changes in search term to reset displayed count and pagination */ @Watch("searchTerm") onSearchTermChange(): void { + // Reset displayed count and pagination when search term changes this.displayedCount = INITIAL_BATCH_SIZE; + this.searchBeforeId = undefined; this.infiniteScrollReset?.(); } /** - * Watch for changes in entities prop to reset displayed count + * Watch for changes in entities prop to clear search and reset displayed count */ @Watch("entities") onEntitiesChange(): void { + // Clear search when entities change (fresh dialog open) + if (this.searchTerm) { + this.searchTerm = ""; + this.filteredEntities = []; + this.searchBeforeId = undefined; + } this.displayedCount = INITIAL_BATCH_SIZE; this.infiniteScrollReset?.(); }