diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index b85e6d09..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,23 +207,30 @@ 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; 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. - * 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[]; @@ -286,6 +298,23 @@ export default class EntityGrid extends Vue { 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; + /** * CSS classes for the empty state message */ @@ -457,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 @@ -508,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 */ @@ -516,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 @@ -535,12 +687,34 @@ 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: check if more 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 + ) { + return !this.isLoadingMore; // Only return true if not already loading + } + // Otherwise, check if more in memory return this.displayedCount < this.entities.length; } @@ -553,16 +727,74 @@ 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; if (container) { const { reset } = useInfiniteScroll( container, - () => { - // Load more: increment displayedCount - this.displayedCount += INCREMENT_SIZE; + 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 @@ -588,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?.(); } diff --git a/src/components/EntitySelectionStep.vue b/src/components/EntitySelectionStep.vue index fd62ccdd..d1ce7a8b 100644 --- a/src/components/EntitySelectionStep.vue +++ b/src/components/EntitySelectionStep.vue @@ -23,6 +23,7 @@ properties * * @author Matthew Raymer */ :you-selectable="youSelectable" :notify="notify" :conflict-context="conflictContext" + :load-more-callback="shouldShowProjects ? loadMoreCallback : undefined" @entity-selected="handleEntitySelected" /> @@ -148,6 +149,10 @@ export default class EntitySelectionStep extends Vue { @Prop() notify?: (notification: NotificationIface, timeout?: number) => void; + /** Callback function to load more projects from server */ + @Prop() + loadMoreCallback?: (entities: PlanData[]) => Promise; + /** * CSS classes for the cancel button */ diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index e08e0021..92d3d68c 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -29,6 +29,11 @@ :unit-code="unitCode" :offer-id="offerId" :notify="$notify" + :load-more-callback=" + giverEntityType === 'project' || recipientEntityType === 'project' + ? handleLoadMoreProjects + : undefined + " @entity-selected="handleEntitySelected" @cancel="cancel" /> @@ -489,9 +494,17 @@ export default class GiftedDialog extends Vue { this.firstStep = false; } - async loadProjects() { + /** + * Load projects from the API + * @param beforeId - Optional rowId for pagination (loads projects before this ID) + */ + async loadProjects(beforeId?: string) { try { - const response = await fetch(this.apiServer + "/api/v2/report/plans", { + let url = this.apiServer + "/api/v2/report/plans"; + if (beforeId) { + url += `?beforeId=${encodeURIComponent(beforeId)}`; + } + const response = await fetch(url, { method: "GET", headers: await getHeaders(this.activeDid), }); @@ -502,14 +515,56 @@ export default class GiftedDialog extends Vue { const results = await response.json(); if (results.data) { - this.projects = results.data; + // Ensure rowId is included in project data + const newProjects = results.data.map( + (plan: PlanData & { rowId?: string }) => ({ + ...plan, + rowId: plan.rowId, + }), + ); + + if (beforeId) { + // Pagination: append new projects + this.projects.push(...newProjects); + } else { + // Initial load: replace array + this.projects = newProjects; + } } } catch (error) { logger.error("Error loading projects:", error); this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD); + // Don't clear existing projects if this was a pagination request + if (!beforeId) { + this.projects = []; + } } } + /** + * Handle loading more projects when EntityGrid reaches the end + * Called by EntitySelectionStep via loadMoreCallback + * @param entities - Current array of projects + */ + async handleLoadMoreProjects( + entities: Array<{ + handleId: string; + rowId?: string; + }>, + ): Promise { + if (entities.length === 0) { + return; + } + + const lastProject = entities[entities.length - 1]; + if (!lastProject.rowId) { + // No rowId means we can't paginate - likely end of data + return; + } + + await this.loadProjects(lastProject.rowId); + } + selectProject(project: PlanData) { this.giver = { did: project.handleId, diff --git a/src/components/MeetingProjectDialog.vue b/src/components/MeetingProjectDialog.vue new file mode 100644 index 00000000..f9c10a58 --- /dev/null +++ b/src/components/MeetingProjectDialog.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/components/ProjectCard.vue b/src/components/ProjectCard.vue index 77d2aa34..4b995a21 100644 --- a/src/components/ProjectCard.vue +++ b/src/components/ProjectCard.vue @@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */ > diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index 33d345f0..31c4f088 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -186,16 +186,59 @@
- +
+
+
+ + +
+
+
+ {{ + selectedProject + ? selectedProject.name || "Unnamed Project" + : "Select Project…" + }} +
+
+ + {{ selectedProjectIssuerName }} +
+
+
+ +