Files
crowd-funder-from-jason/src/components/EntityGrid.vue
Jose Olarte III 2f89c7e13b 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.
2025-11-12 21:06:20 +08:00

862 lines
27 KiB
Vue

/** * EntityGrid.vue - Unified entity grid layout component * * Extracted from
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */
<template>
<!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'">
<!-- "You" entity -->
<SpecialEntityCard
v-if="showYouEntity && !searchTerm.trim()"
entity-type="you"
label="You"
icon="hand"
:selectable="youSelectable"
:conflicted="youConflicted"
:entity-data="youEntityData"
:notify="notify"
:conflict-context="conflictContext"
@entity-selected="handleEntitySelected"
/>
<!-- "Unnamed" entity -->
<SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed"
:label="unnamedEntityName"
icon="circle-question"
:entity-data="unnamedEntityData"
:notify="notify"
:conflict-context="conflictContext"
@entity-selected="handleEntitySelected"
/>
</template>
<!-- Empty state message -->
<li v-if="entities.length === 0" :class="emptyStateClasses">
{{ emptyStateMessage }}
</li>
<!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard
v-for="person in displayedEntities as Contact[]"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<template v-else-if="entityType === 'projects'">
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
</ul>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue";
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
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/**
* EntityGrid - Unified grid layout for displaying people or projects
*
* Features:
* - Responsive grid layout for people/projects
* - Special entity integration (You, Unnamed)
* - Conflict detection integration
* - Empty state messaging
* - Event delegation for entity selection
* - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties
* - Configurable entity display logic via function props
*/
@Component({
components: {
PersonCard,
ProjectCard,
SpecialEntityCard,
},
mixins: [PlatformServiceMixin],
})
export default class EntityGrid extends Vue {
/** Type of entities to display */
@Prop({ required: true })
entityType!: "people" | "projects";
// Search state
searchTerm = "";
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;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
isLoadingMore = false; // Prevent duplicate callback calls
/**
* 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.
*
* 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[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Function to check if a person DID would create a conflict */
@Prop({ required: true })
conflictChecker!: (did: string) => boolean;
/** Whether to show the "You" entity for people grids */
@Prop({ default: true })
showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */
@Prop({ default: true })
youSelectable!: boolean;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Context for conflict messages (e.g., "giver", "recipient") */
@Prop({ default: "other party" })
conflictContext!: string;
/**
* Function to determine which entities to display (allows parent control)
*
* This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering and sorting.
* Note: Infinite scroll is disabled when this prop is provided.
*
* @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects")
* @returns Filtered/sorted array of entities to display
*
* @example
* // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl)"
*
* @example
* // Custom sorting: sort projects by name
* :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name))"
*/
@Prop({ default: null })
displayEntitiesFunction?: (
entities: Contact[] | PlanData[],
entityType: "people" | "projects",
) => Contact[] | PlanData[];
/**
* Optional callback function to load more entities from server
* Called when infinite scroll reaches end and more data is available
* Required for projects when using server-side pagination
*
* @param entities - Current array of entities
* @returns Promise that resolves when more entities are loaded
*
* @example
* :load-more-callback="async (entities) => {
* const lastEntity = entities[entities.length - 1];
* await loadMoreFromServer(lastEntity.rowId);
* }"
*/
@Prop({ default: null })
loadMoreCallback?: (entities: Contact[] | PlanData[]) => Promise<void>;
/**
* CSS classes for the empty state message
*/
get emptyStateClasses(): string {
return "text-xs text-slate-500 italic col-span-full";
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/
get displayedEntities(): Contact[] | PlanData[] {
// If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
}
/**
* Get the most recently added contacts (when showing contacts and not searching)
*
* NOTE: This assumes entities are already sorted by date added (newest first).
* See the entities prop documentation for details on using $contactsByDateAdded().
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first few (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = this.entities as Contact[];
const sorted = [...remaining].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);
});
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
}
/**
* Computed empty state message based on entity type
*/
get emptyStateMessage(): string {
if (this.entityType === "projects") {
return "(No projects found.)";
} else {
return "(Add friends to see more people worthy of recognition.)";
}
}
/**
* Whether the "You" entity is conflicted
*/
get youConflicted(): boolean {
return this.conflictChecker(this.activeDid);
}
/**
* Entity data for the "You" special entity
*/
get youEntityData(): { did: string; name: string } {
return {
did: this.activeDid,
name: "You",
};
}
/**
* Entity data for the "Unnamed" special entity
*/
get unnamedEntityData(): { did: string; name: string } {
return {
did: "",
name: UNNAMED_ENTITY_NAME,
};
}
/**
* Get the unnamed entity name constant
*/
get unnamedEntityName(): string {
return UNNAMED_ENTITY_NAME;
}
/**
* Check if a person DID is conflicted
*/
isPersonConflicted(did: string): boolean {
return this.conflictChecker(did);
}
/**
* Handle person selection from PersonCard
*/
handlePersonSelected(person: Contact): void {
this.emitEntitySelected({
type: "person",
data: person,
});
}
/**
* Handle project selection from ProjectCard
*/
handleProjectSelected(project: PlanData): void {
this.emitEntitySelected({
type: "project",
data: project,
});
}
/**
* Handle special entity selection from SpecialEntityCard
* Treat "You" and "Unnamed" as person entities
*/
handleEntitySelected(event: { data: { did?: string; name: string } }): void {
// Convert special entities to person entities since they represent people
this.emitEntitySelected({
type: "person",
data: event.data as Contact,
});
}
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* 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 {
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
await this.performProjectSearch();
} else {
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
}
// Reset displayed count when search completes
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* 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
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// 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 and callback exists, callback handles server-side availability
if (
this.displayedCount >= this.entities.length &&
this.loadMoreCallback
) {
return !this.isLoadingMore; // Only return true if not already loading
}
// Otherwise, check if more in memory
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = recent + all alphabetical
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
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;
if (container) {
const { reset } = useInfiniteScroll(
container,
async () => {
// 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 {
// 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;
}
}
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator
@Emit("entity-selected")
emitEntitySelected(data: {
type: "person" | "project";
data: Contact | PlanData;
}): {
type: "person" | "project";
data: Contact | PlanData;
} {
return data;
}
/**
* 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 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?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
}
</script>
<style scoped>
/* Grid-specific styles if needed */
</style>