Browse Source

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.
pull/223/head
Jose Olarte III 19 hours ago
parent
commit
2f89c7e13b
  1. 313
      src/components/EntityGrid.vue

313
src/components/EntityGrid.vue

@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records"; import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; 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 * Constants for infinite scroll configuration
@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
ProjectCard, ProjectCard,
SpecialEntityCard, SpecialEntityCard,
}, },
mixins: [PlatformServiceMixin],
}) })
export default class EntityGrid extends Vue { export default class EntityGrid extends Vue {
/** Type of entities to display */ /** Type of entities to display */
@ -202,6 +207,11 @@ export default class EntityGrid extends Vue {
isSearching = false; isSearching = false;
searchTimeout: NodeJS.Timeout | null = null; searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = []; filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Infinite scroll state // Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE; displayedCount = INITIAL_BATCH_SIZE;
@ -212,14 +222,15 @@ export default class EntityGrid extends Vue {
/** /**
* Array of entities to display * 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 * IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly. * (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 * For projects: Can be partial list (pagination supported).
* by date added and simply takes the first 3. If contacts are sorted * Server-side search will fetch matching results with pagination,
* alphabetically or in another order, the wrong contacts will appear in * regardless of what's in this prop.
* "Recently Added".
*/ */
@Prop({ required: true }) @Prop({ required: true })
entities!: Contact[] | PlanData[]; entities!: Contact[] | PlanData[];
@ -475,47 +486,27 @@ export default class EntityGrid extends Vue {
/** /**
* Perform the actual search * Perform the actual search
* Routes to server-side search for projects or client-side filtering for contacts
*/ */
async performSearch(): Promise<void> { async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) { if (!this.searchTerm.trim()) {
this.filteredEntities = []; this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
return; return;
} }
this.isSearching = true; this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try { try {
// Simulate async search (in case we need to add API calls later) if (this.entityType === "projects") {
await new Promise((resolve) => setTimeout(resolve, 100)); // Server-side search for projects (initial load, no beforeId)
await this.performProjectSearch();
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);
});
} else { } else {
this.filteredEntities = (this.entities as PlanData[]) // Client-side filtering for contacts (complete list)
.filter((project: PlanData) => { await this.performContactSearch();
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());
});
} }
// Reset displayed count when search completes // 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<void> {
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<void> {
// 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 * Clear the search
*/ */
@ -534,6 +667,7 @@ export default class EntityGrid extends Vue {
this.filteredEntities = []; this.filteredEntities = [];
this.isSearching = false; this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
// Clear any pending timeout // Clear any pending timeout
@ -553,13 +687,27 @@ export default class EntityGrid extends Vue {
} }
if (this.searchTerm.trim()) { if (this.searchTerm.trim()) {
// Search mode: check filtered entities // Search mode: check if more results available
return this.displayedCount < this.filteredEntities.length; 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") { if (this.entityType === "projects") {
// Projects: if we've shown all loaded entities, callback handles server-side availability // Projects: if we've shown all loaded entities and callback exists, callback handles server-side availability
// If callback exists and we've reached the end, assume more might be available
if ( if (
this.displayedCount >= this.entities.length && this.displayedCount >= this.entities.length &&
this.loadMoreCallback this.loadMoreCallback
@ -579,7 +727,13 @@ export default class EntityGrid extends Vue {
/** /**
* Initialize infinite scroll on mount * Initialize infinite scroll on mount
*/ */
mounted(): void { async mounted(): Promise<void> {
// Load apiServer for project searches
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
}
this.$nextTick(() => { this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement; const container = this.$refs.scrollContainer as HTMLElement;
@ -587,28 +741,59 @@ export default class EntityGrid extends Vue {
const { reset } = useInfiniteScroll( const { reset } = useInfiniteScroll(
container, container,
async () => { async () => {
// For projects: if we've shown all entities and callback exists, call it // Search mode: handle search pagination
if ( if (this.searchTerm.trim()) {
this.entityType === "projects" && if (this.entityType === "projects") {
this.displayedCount >= this.entities.length && // Projects: load more search results if available
this.loadMoreCallback && if (
!this.isLoadingMore this.displayedCount >= this.filteredEntities.length &&
) { this.searchBeforeId &&
this.isLoadingMore = true; !this.isLoadingSearchMore
try { ) {
await this.loadMoreCallback(this.entities); this.isLoadingSearchMore = true;
// After callback, entities prop will update via Vue reactivity try {
// Reset scroll state to allow further loading await this.performProjectSearch(this.searchBeforeId);
this.infiniteScrollReset?.(); // After loading more, reset scroll state to allow further loading
} catch (error) { this.infiniteScrollReset?.();
// Error handling is up to the callback, but we should reset loading state } catch (error) {
console.error("Error in loadMoreCallback:", error); logger.error("Error loading more search results:", error);
} finally { // Error already handled in performProjectSearch
this.isLoadingMore = false; } 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 { } else {
// Normal case: increment displayedCount to show more from memory // Non-search mode: existing logic
this.displayedCount += INCREMENT_SIZE; // 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") @Watch("searchTerm")
onSearchTermChange(): void { onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); 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") @Watch("entities")
onEntitiesChange(): void { 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.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
} }

Loading…
Cancel
Save