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 17 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 { 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<void> {
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<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
*/
@ -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<void> {
// 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?.();
}

Loading…
Cancel
Save