diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index b85e6d09..02de2b28 100644 --- a/src/components/EntityGrid.vue +++ b/src/components/EntityGrid.vue @@ -207,6 +207,7 @@ export default class EntityGrid extends Vue { displayedCount = INITIAL_BATCH_SIZE; infiniteScrollReset?: () => void; scrollContainer?: HTMLElement; + isLoadingMore = false; // Prevent duplicate callback calls /** * Array of entities to display @@ -286,6 +287,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 */ @@ -540,7 +558,15 @@ export default class EntityGrid extends Vue { } if (this.entityType === "projects") { - // Projects: check if more available + // 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 + 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; } @@ -560,9 +586,30 @@ export default class EntityGrid extends Vue { if (container) { const { reset } = useInfiniteScroll( container, - () => { - // Load more: increment displayedCount - this.displayedCount += INCREMENT_SIZE; + 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; + } + } else { + // Normal case: increment displayedCount to show more from memory + this.displayedCount += INCREMENT_SIZE; + } }, { distance: 50, // pixels from bottom 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 index 5cf0d784..f9c10a58 100644 --- a/src/components/MeetingProjectDialog.vue +++ b/src/components/MeetingProjectDialog.vue @@ -16,6 +16,7 @@ :show-unnamed-entity="false" :notify="notify" :conflict-context="'project'" + :load-more-callback="loadMoreCallback" @entity-selected="handleEntitySelected" /> @@ -77,6 +78,10 @@ export default class MeetingProjectDialog extends Vue { @Prop() notify?: (notification: NotificationIface, timeout?: number) => void; + /** Callback function to load more projects from server */ + @Prop() + loadMoreCallback?: (entities: PlanData[]) => Promise; + /** * Handle entity selection from EntityGrid * Immediately assigns the selected project and closes the dialog diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index a37c0aa4..31c4f088 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -274,6 +274,7 @@ :all-my-dids="allMyDids" :all-contacts="allContacts" :notify="$notify" + :load-more-callback="handleLoadMoreProjects" @assign="handleProjectLinkAssigned" /> @@ -785,38 +786,78 @@ export default class OnboardMeetingView extends Vue { /** * Load projects from the API + * @param beforeId - Optional rowId for pagination (loads projects before this ID) */ - async loadProjects() { + async loadProjects(beforeId?: string) { try { const headers = await getHeaders(this.activeDid); - const url = `${this.apiServer}/api/v2/report/plans`; + let url = `${this.apiServer}/api/v2/report/plans`; + if (beforeId) { + url += `?beforeId=${encodeURIComponent(beforeId)}`; + } const resp = await this.axios.get(url, { headers }); if (resp.status === 200 && resp.data.data) { - this.allProjects = resp.data.data.map( + const newProjects = resp.data.data.map( (plan: { name: string; description: string; image?: string; handleId: string; issuerDid: string; + rowId?: string; }) => ({ name: plan.name, description: plan.description, image: plan.image, handleId: plan.handleId, issuerDid: plan.issuerDid, + rowId: plan.rowId, }), ); + + if (beforeId) { + // Pagination: append new projects + this.allProjects.push(...newProjects); + } else { + // Initial load: replace array + this.allProjects = newProjects; + } } } catch (error) { this.$logAndConsole( "Error loading projects: " + errorStringForLog(error), true, ); - // Don't show error to user - just leave projects empty - this.allProjects = []; + // Don't show error to user - just leave projects empty (or keep existing if pagination) + if (!beforeId) { + this.allProjects = []; + } + } + } + + /** + * Handle loading more projects when EntityGrid reaches the end + * Called by MeetingProjectDialog 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); } /**