From 6bf4055c2f9e3f0935144fefedad8c73b6055adc Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 12 Nov 2025 17:10:03 +0800 Subject: [PATCH 1/8] feat: add pagination support for project lists in dialogs Add server-side pagination to EntityGrid component for projects, enabling infinite scrolling to load all available projects instead of stopping after the initial batch. Changes: - EntityGrid: Add loadMoreCallback prop to trigger server-side loading when scroll reaches end of loaded projects - OnboardMeetingSetupView: Update loadProjects() to support pagination with beforeId parameter and add handleLoadMoreProjects() callback - MeetingProjectDialog: Accept and pass through loadMoreCallback to EntityGrid - GiftedDialog: Add pagination support to loadProjects() and handleLoadMoreProjects() callback - EntitySelectionStep: Accept and pass through loadMoreCallback prop to EntityGrid when showing projects This ensures users can access all projects in MeetingProjectDialog and GiftedDialog by automatically loading more as they scroll, matching the behavior already present in DiscoverView. All project uses of EntityGrid now use pagination by default. --- src/components/EntityGrid.vue | 55 ++++++++++++++++++++-- src/components/EntitySelectionStep.vue | 5 ++ src/components/GiftedDialog.vue | 61 +++++++++++++++++++++++-- src/components/MeetingProjectDialog.vue | 5 ++ src/views/OnboardMeetingSetupView.vue | 51 +++++++++++++++++++-- 5 files changed, 165 insertions(+), 12 deletions(-) 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,40 +786,80 @@ 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); + } + /** * Computed property for selected project * Derives the project from projectLink by finding it in allProjects From 2f89c7e13b4682c4dc140f02e44919854e9fb8a4 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Wed, 12 Nov 2025 21:06:20 +0800 Subject: [PATCH 2/8] 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. --- src/components/EntityGrid.vue | 313 +++++++++++++++++++++++++++------- 1 file changed, 253 insertions(+), 60 deletions(-) diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index 02de2b28..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,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 { 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 { + 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 */ @@ -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 { + // 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?.(); } From d37e53b1a942e27284308d8e4b9e06dbf8bc1edd Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 13 Nov 2025 18:10:35 +0800 Subject: [PATCH 3/8] fix: pause MembersList auto-refresh during project dialog interaction Stop auto-refresh when MeetingProjectDialog opens and resume when it closes to prevent UI conflicts during project selection. --- src/components/MeetingProjectDialog.vue | 12 ++++++++++++ src/views/OnboardMeetingSetupView.vue | 23 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/components/MeetingProjectDialog.vue b/src/components/MeetingProjectDialog.vue index f9c10a58..a516cdfb 100644 --- a/src/components/MeetingProjectDialog.vue +++ b/src/components/MeetingProjectDialog.vue @@ -107,6 +107,7 @@ export default class MeetingProjectDialog extends Vue { */ open(): void { this.visible = true; + this.emitOpen(); } /** @@ -114,6 +115,7 @@ export default class MeetingProjectDialog extends Vue { */ close(): void { this.visible = false; + this.emitClose(); } // Emit methods using @Emit decorator @@ -122,6 +124,16 @@ export default class MeetingProjectDialog extends Vue { emitAssign(project: PlanData): PlanData { return project; } + + @Emit("open") + emitOpen(): void { + // Emit when dialog opens + } + + @Emit("close") + emitClose(): void { + // Emit when dialog closes + } } diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index 31c4f088..79f9c4db 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -276,6 +276,8 @@ :notify="$notify" :load-more-callback="handleLoadMoreProjects" @assign="handleProjectLinkAssigned" + @open="handleDialogOpen" + @close="handleDialogClose" /> @@ -308,6 +310,7 @@ From 3ecae0be0f127f19b91a46888edd56d7490579cf Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 13 Nov 2025 19:21:22 +0800 Subject: [PATCH 4/8] refactor(OnboardMeetingSetupView): fix selected project display after refresh Refactor selectedProject computation to use separate storage instead of relying on allProjects array. This fixes a bug where the selected project wouldn't display after page refresh if it wasn't in the initial allProjects batch. Changes: - Add selectedProjectData property to store selected project independently - Simplify selectedProject computed to return selectedProjectData directly - Add fetchProjectByHandleId() to fetch single project by handleId - Add ensureSelectedProjectLoaded() to check allProjects first, then fetch - Update handleProjectLinkAssigned() to store directly in selectedProjectData - Remove band-aid solution of adding selected projects to allProjects array - Update startEditing() and cancelEditing() to ensure selected project loads - Call ensureSelectedProjectLoaded() in created() lifecycle hook This ensures the selected project always displays correctly, even when: - Selected from search results (not in allProjects) - Page is refreshed (allProjects reloads without selected project) - Project is in a later pagination batch --- src/views/OnboardMeetingSetupView.vue | 93 +++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index 79f9c4db..ea2cf832 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -421,6 +421,7 @@ export default class OnboardMeetingView extends Vue { allProjects: PlanData[] = []; allContacts: Contact[] = []; allMyDids: string[] = []; + selectedProjectData: PlanData | null = null; get minDateTime() { const now = new Date(); now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future @@ -447,6 +448,10 @@ export default class OnboardMeetingView extends Vue { await this.loadProjects(); await this.fetchCurrentMeeting(); + + // Ensure selected project is loaded if projectLink exists + await this.ensureSelectedProjectLoaded(); + this.isLoading = false; } @@ -518,6 +523,65 @@ export default class OnboardMeetingView extends Vue { } } + /** + * Ensure the selected project is loaded if projectLink exists + * Checks allProjects first, then fetches if not found + */ + async ensureSelectedProjectLoaded(): Promise { + const projectLink = + this.currentMeeting?.projectLink || + this.newOrUpdatedMeetingInputs?.projectLink; + + if (!projectLink) { + this.selectedProjectData = null; + return; + } + + // Check if already loaded in allProjects + const existingProject = this.allProjects.find( + (p) => p.handleId === projectLink, + ); + if (existingProject) { + this.selectedProjectData = existingProject; + return; + } + + // Not in allProjects, fetch it + await this.fetchProjectByHandleId(projectLink); + } + + /** + * Fetch a single project by handleId + * @param handleId - The project handleId to fetch + */ + async fetchProjectByHandleId(handleId: string): Promise { + try { + const headers = await getHeaders(this.activeDid); + const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`; + const resp = await this.axios.get(url, { headers }); + + if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) { + const project = resp.data.data[0]; + this.selectedProjectData = { + name: project.name, + description: project.description, + image: project.image, + handleId: project.handleId, + issuerDid: project.issuerDid, + rowId: project.rowId, + }; + } else { + this.selectedProjectData = null; + } + } catch (error) { + this.$logAndConsole( + "Error fetching project by handleId: " + errorStringForLog(error), + true, + ); + this.selectedProjectData = null; + } + } + async createMeeting() { this.isLoading = true; @@ -652,7 +716,7 @@ export default class OnboardMeetingView extends Vue { } } - startEditing() { + async startEditing() { // Populate form with existing meeting data if (this.currentMeeting) { const localExpiresAt = new Date(this.currentMeeting.expiresAt); @@ -663,6 +727,10 @@ export default class OnboardMeetingView extends Vue { password: this.currentMeeting.password || "", projectLink: this.currentMeeting.projectLink || "", }; + // Ensure selected project is loaded if projectLink exists + if (this.currentMeeting.projectLink) { + await this.ensureSelectedProjectLoaded(); + } } else { this.$logError( "There is no current meeting to edit. We should never get here.", @@ -670,9 +738,15 @@ export default class OnboardMeetingView extends Vue { } } - cancelEditing() { + async cancelEditing() { // Reset form data this.newOrUpdatedMeetingInputs = null; + // Restore selected project from currentMeeting if it exists + if (this.currentMeeting?.projectLink) { + await this.ensureSelectedProjectLoaded(); + } else { + this.selectedProjectData = null; + } } async updateMeeting() { @@ -865,17 +939,10 @@ export default class OnboardMeetingView extends Vue { /** * Computed property for selected project - * Derives the project from projectLink by finding it in allProjects + * Returns the separately stored selected project data */ get selectedProject(): PlanData | null { - if (!this.newOrUpdatedMeetingInputs?.projectLink) { - return null; - } - return ( - this.allProjects.find( - (p) => p.handleId === this.newOrUpdatedMeetingInputs?.projectLink, - ) || null - ); + return this.selectedProjectData; } /** @@ -905,6 +972,9 @@ export default class OnboardMeetingView extends Vue { * Handle project assignment from dialog */ handleProjectLinkAssigned(project: PlanData): void { + // Store the selected project directly + this.selectedProjectData = project; + if (this.newOrUpdatedMeetingInputs) { this.newOrUpdatedMeetingInputs.projectLink = project.handleId; } @@ -914,6 +984,7 @@ export default class OnboardMeetingView extends Vue { * Unset the project link and revert to initial state */ unsetProjectLink(): void { + this.selectedProjectData = null; if (this.newOrUpdatedMeetingInputs) { this.newOrUpdatedMeetingInputs.projectLink = ""; } From e793d7a9e2f9c6fe6ff358d8140b6d987276a2d0 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 13 Nov 2025 21:24:52 +0800 Subject: [PATCH 5/8] refactor: defer project loading until MeetingProjectDialog opens - Move loadProjects() call from created() to handleDialogOpen() - Remove allProjects check from ensureSelectedProjectLoaded() - Projects now load only when dialog is opened, improving initial page load performance - ensureSelectedProjectLoaded() now directly fetches project by handleId when needed --- src/views/OnboardMeetingSetupView.vue | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue index ea2cf832..4773a2dc 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -444,9 +444,6 @@ export default class OnboardMeetingView extends Vue { // eslint-disable-next-line @typescript-eslint/no-explicit-any this.allMyDids = await (this as any).$getAllAccountDids(); - // Load projects - await this.loadProjects(); - await this.fetchCurrentMeeting(); // Ensure selected project is loaded if projectLink exists @@ -525,7 +522,6 @@ export default class OnboardMeetingView extends Vue { /** * Ensure the selected project is loaded if projectLink exists - * Checks allProjects first, then fetches if not found */ async ensureSelectedProjectLoaded(): Promise { const projectLink = @@ -537,16 +533,6 @@ export default class OnboardMeetingView extends Vue { return; } - // Check if already loaded in allProjects - const existingProject = this.allProjects.find( - (p) => p.handleId === projectLink, - ); - if (existingProject) { - this.selectedProjectData = existingProject; - return; - } - - // Not in allProjects, fetch it await this.fetchProjectByHandleId(projectLink); } @@ -991,13 +977,18 @@ export default class OnboardMeetingView extends Vue { } /** - * Handle dialog open event - stop auto-refresh in MembersList + * Handle dialog open event - stop auto-refresh in MembersList and load projects */ - handleDialogOpen(): void { + async handleDialogOpen(): Promise { const membersList = this.$refs.membersList as MembersList; if (membersList) { membersList.stopAutoRefresh(); } + + // Load projects when dialog opens (if not already loaded) + if (this.allProjects.length === 0) { + await this.loadProjects(); + } } /** From acf104eaa7ed3bfa5557367a151424ae76fe31f5 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Thu, 13 Nov 2025 21:41:34 +0800 Subject: [PATCH 6/8] refactor: remove debug loggers from EntityGrid component Remove three logger.debug() calls used for debugging project search results and pagination state. Error logging remains intact. --- src/components/EntityGrid.vue | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index c0daf348..e79bc799 100644 --- a/src/components/EntityGrid.vue +++ b/src/components/EntityGrid.vue @@ -567,19 +567,6 @@ export default class EntityGrid extends Vue { }), ); - 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); @@ -594,15 +581,8 @@ export default class EntityGrid extends Vue { 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) { From cb75b25529ef0780e16c380956a563074b99877f Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 17 Nov 2025 19:49:17 +0800 Subject: [PATCH 7/8] refactor: consolidate project loading into EntityGrid component Unify project loading and searching logic in EntityGrid.vue to eliminate duplication. Make entities prop optional for projects, add internal project state, and auto-load projects when needed. - EntityGrid: Combine search/load into fetchProjects(), add internal allProjects state, handle pagination internally for both search and load modes - OnboardMeetingSetupView: Remove project loading methods - MeetingProjectDialog: Remove project props - GiftedDialog: Remove project loading logic - EntitySelectionStep: Make projects prop optional Reduces code duplication by ~150 lines and simplifies component APIs. All project selection now uses EntityGrid's internal loading. --- src/components/EntityGrid.vue | 331 ++++++++++++++++++------ src/components/EntitySelectionStep.vue | 13 +- src/components/GiftedDialog.vue | 89 ------- src/components/MeetingProjectDialog.vue | 10 - src/views/OnboardMeetingSetupView.vue | 88 +------ 5 files changed, 265 insertions(+), 266 deletions(-) diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index e79bc799..c2ae4fde 100644 --- a/src/components/EntityGrid.vue +++ b/src/components/EntityGrid.vue @@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */ -
  • +
  • {{ emptyStateMessage }}
  • @@ -213,6 +213,11 @@ export default class EntityGrid extends Vue { // API server for project searches apiServer = ""; + // Internal project state (when entities prop not provided for projects) + allProjects: PlanData[] = []; + loadBeforeId: string | undefined = undefined; + isLoadingProjects = false; + // Infinite scroll state displayedCount = INITIAL_BATCH_SIZE; infiniteScrollReset?: () => void; @@ -222,18 +227,17 @@ export default class EntityGrid extends Vue { /** * Array of entities to display * - * For contacts: Must be a COMPLETE list from local database. + * For contacts (entityType === 'people'): REQUIRED - 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. + * For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads + * projects internally from the API server. If provided, uses the provided list. */ - @Prop({ required: true }) - entities!: Contact[] | PlanData[]; + @Prop({ required: false }) + entities?: Contact[] | PlanData[]; /** Active user's DID */ @Prop({ required: true }) @@ -322,6 +326,33 @@ export default class EntityGrid extends Vue { return "text-xs text-slate-500 italic col-span-full"; } + /** + * Check if there are no entities to display + */ + get hasNoEntities(): boolean { + if (this.entityType === "projects") { + // For projects: check internal state if no entities prop, otherwise check prop + const projectsToCheck = this.entities || this.allProjects; + return projectsToCheck.length === 0; + } else { + // For people: entities prop is required + return !this.entities || this.entities.length === 0; + } + } + + /** + * Get the entities array to use (prop or internal state) + */ + get entitiesToUse(): Contact[] | PlanData[] { + if (this.entityType === "projects") { + // For projects: use prop if provided, otherwise use internal state + return this.entities || this.allProjects; + } else { + // For people: entities prop is required + return this.entities || []; + } + } + /** * Computed entities to display - uses function prop if provided, otherwise uses infinite scroll * When searching, returns filtered results with infinite scroll applied @@ -334,12 +365,12 @@ export default class EntityGrid extends Vue { // If custom function provided, use it (disables infinite scroll) if (this.displayEntitiesFunction) { - return this.displayEntitiesFunction(this.entities, this.entityType); + return this.displayEntitiesFunction(this.entitiesToUse, this.entityType); } // Default: projects use infinite scroll if (this.entityType === "projects") { - return (this.entities as PlanData[]).slice(0, this.displayedCount); + return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount); } // People: handled by recentContacts + alphabeticalContacts (both use displayedCount) @@ -353,7 +384,11 @@ export default class EntityGrid extends Vue { * See the entities prop documentation for details on using $contactsByDateAdded(). */ get recentContacts(): Contact[] { - if (this.entityType !== "people" || this.searchTerm.trim()) { + if ( + this.entityType !== "people" || + this.searchTerm.trim() || + !this.entities + ) { return []; } // Entities are already sorted by date added (newest first) @@ -365,7 +400,11 @@ export default class EntityGrid extends Vue { * Uses infinite scroll to control how many are displayed */ get alphabeticalContacts(): Contact[] { - if (this.entityType !== "people" || this.searchTerm.trim()) { + if ( + this.entityType !== "people" || + this.searchTerm.trim() || + !this.entities + ) { return []; } // Skip the first few (recent contacts) and sort the rest alphabetically @@ -503,7 +542,8 @@ export default class EntityGrid extends Vue { try { if (this.entityType === "projects") { // Server-side search for projects (initial load, no beforeId) - await this.performProjectSearch(); + const searchLower = this.searchTerm.toLowerCase().trim(); + await this.fetchProjects(undefined, searchLower); } else { // Client-side filtering for contacts (complete list) await this.performContactSearch(); @@ -518,15 +558,24 @@ 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. + * Fetch projects from API server + * Unified method for both loading all projects and searching projects. + * If claimContents is provided, performs search and updates filteredEntities. + * If claimContents is not provided, loads all projects and updates allProjects. * * @param beforeId - Optional rowId for pagination (loads projects before this ID) + * @param claimContents - Optional search term (if provided, performs search; if not, loads all) */ - async performProjectSearch(beforeId?: string): Promise { + async fetchProjects( + beforeId?: string, + claimContents?: string, + ): Promise { if (!this.apiServer) { - this.filteredEntities = []; + if (claimContents) { + this.filteredEntities = []; + } else { + this.allProjects = []; + } if (this.notify) { this.notify( { @@ -541,11 +590,21 @@ export default class EntityGrid extends Vue { return; } - const searchLower = this.searchTerm.toLowerCase().trim(); - let url = `${this.apiServer}/api/v2/report/plans?claimContents=${encodeURIComponent(searchLower)}`; + const isSearch = !!claimContents; + let url = `${this.apiServer}/api/v2/report/plans`; + // Build query parameters + const params: string[] = []; + if (claimContents) { + params.push( + `claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`, + ); + } if (beforeId) { - url += `&beforeId=${encodeURIComponent(beforeId)}`; + params.push(`beforeId=${encodeURIComponent(beforeId)}`); + } + if (params.length > 0) { + url += `?${params.join("&")}`; } try { @@ -555,7 +614,9 @@ export default class EntityGrid extends Vue { }); if (response.status !== 200) { - throw new Error("Failed to search projects"); + throw new Error( + isSearch ? "Failed to search projects" : "Failed to load projects", + ); } const results = await response.json(); @@ -567,44 +628,84 @@ export default class EntityGrid extends Vue { }), ); - if (beforeId) { - // Pagination: append new projects to existing search results - this.filteredEntities.push(...newProjects); - } else { - // Initial search: replace array - this.filteredEntities = newProjects; - } + if (isSearch) { + // Search mode: update filteredEntities + 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; + // Update searchBeforeId for next pagination + if (newProjects.length > 0) { + const lastProject = newProjects[newProjects.length - 1]; + this.searchBeforeId = lastProject.rowId || undefined; + } else { + this.searchBeforeId = undefined; // No more results + } } else { - this.searchBeforeId = undefined; // No more results + // Load mode: update allProjects + if (beforeId) { + // Pagination: append new projects + this.allProjects.push(...newProjects); + } else { + // Initial load: replace array + this.allProjects = newProjects; + } + + // Update loadBeforeId for next pagination + if (newProjects.length > 0) { + const lastProject = newProjects[newProjects.length - 1]; + this.loadBeforeId = lastProject.rowId || undefined; + } else { + this.loadBeforeId = undefined; // No more results + } } } else { + // No data in response + if (isSearch) { + if (!beforeId) { + // Only clear on initial search, not pagination + this.filteredEntities = []; + } + this.searchBeforeId = undefined; + } else { + if (!beforeId) { + // Only clear on initial load, not pagination + this.allProjects = []; + } + this.loadBeforeId = undefined; + } + } + } catch (error) { + logger.error( + `Error ${isSearch ? "searching" : "loading"} projects:`, + error, + ); + if (isSearch) { if (!beforeId) { - // Only clear on initial search, not pagination + // Only clear on initial search error, not pagination error this.filteredEntities = []; } this.searchBeforeId = undefined; + } else { + if (!beforeId) { + // Only clear on initial load error, not pagination error + this.allProjects = []; + } + this.loadBeforeId = 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.", + text: isSearch + ? "Failed to search projects. Please try again." + : "Failed to load projects. Please try again.", }, TIMEOUTS.STANDARD, ); @@ -617,6 +718,11 @@ export default class EntityGrid extends Vue { * Assumes entities prop contains complete contact list from local database */ async performContactSearch(): Promise { + if (!this.entities) { + this.filteredEntities = []; + return; + } + // Simulate async (for consistency with project search) await new Promise((resolve) => setTimeout(resolve, 100)); @@ -685,21 +791,39 @@ export default class EntityGrid extends Vue { } } - // Non-search mode: existing logic + // Non-search mode if (this.entityType === "projects") { - // Projects: if we've shown all loaded entities and callback exists, callback handles server-side availability + // Projects: check internal state or prop + const projectsToCheck = this.entities || this.allProjects; + const beforeId = this.entities ? undefined : this.loadBeforeId; + + // 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 beforeId to load more (and not using entities prop) + const hasMoreLoaded = this.displayedCount < projectsToCheck.length; + const canLoadMoreFromServer = + !this.entities && + this.displayedCount >= projectsToCheck.length && + !!beforeId && + !this.isLoadingProjects; + + // Also check if loadMoreCallback is provided (for backward compatibility) if ( + this.entities && this.displayedCount >= this.entities.length && this.loadMoreCallback ) { - return !this.isLoadingMore; // Only return true if not already loading + return !this.isLoadingMore; } - // Otherwise, check if more in memory - return this.displayedCount < this.entities.length; + + return hasMoreLoaded || canLoadMoreFromServer; } // People: check if more alphabetical contacts available // Total available = recent + all alphabetical + if (!this.entities) { + return false; + } const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length; return this.displayedCount < totalAvailable; } @@ -708,10 +832,40 @@ export default class EntityGrid extends Vue { * Initialize infinite scroll on mount */ async mounted(): Promise { - // Load apiServer for project searches + // Load apiServer for project searches/loads if (this.entityType === "projects") { const settings = await this.$accountSettings(); this.apiServer = settings.apiServer || ""; + + // Load projects on mount if entities prop not provided + if (!this.entities && this.apiServer) { + this.isLoadingProjects = true; + try { + await this.fetchProjects(); + } catch (error) { + logger.error("Error loading projects on mount:", error); + } finally { + this.isLoadingProjects = false; + } + } + } + + // Validate entities prop for people + if (this.entityType === "people" && !this.entities) { + logger.error( + "EntityGrid: entities prop is required when entityType is 'people'", + ); + if (this.notify) { + this.notify( + { + group: "alert", + type: "danger", + title: "Error", + text: "Contacts data is required but not provided.", + }, + TIMEOUTS.SHORT, + ); + } } this.$nextTick(() => { @@ -732,12 +886,13 @@ export default class EntityGrid extends Vue { ) { this.isLoadingSearchMore = true; try { - await this.performProjectSearch(this.searchBeforeId); + const searchLower = this.searchTerm.toLowerCase().trim(); + await this.fetchProjects(this.searchBeforeId, searchLower); // 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 + // Error already handled in fetchProjects } finally { this.isLoadingSearchMore = false; } @@ -750,28 +905,54 @@ export default class EntityGrid extends Vue { 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; + // Non-search mode + if (this.entityType === "projects") { + const projectsToCheck = this.entities || this.allProjects; + const beforeId = this.entities ? undefined : this.loadBeforeId; + + // If using internal state and need to load more from server + if ( + !this.entities && + this.displayedCount >= projectsToCheck.length && + beforeId && + !this.isLoadingProjects + ) { + this.isLoadingProjects = true; + try { + await this.fetchProjects(beforeId); + // After loading more, reset scroll state to allow further loading + this.infiniteScrollReset?.(); + } catch (error) { + logger.error("Error loading more projects:", error); + // Error already handled in fetchProjects + } finally { + this.isLoadingProjects = false; + } + } else if ( + this.entities && + this.displayedCount >= this.entities.length && + this.loadMoreCallback && + !this.isLoadingMore + ) { + // Backward compatibility: use loadMoreCallback if provided + 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 + logger.error("Error in loadMoreCallback:", error); + } finally { + this.isLoadingMore = false; + } + } else { + // Normal case: increment displayedCount to show more from memory + this.displayedCount += INCREMENT_SIZE; } } else { - // Normal case: increment displayedCount to show more from memory + // People: increment displayedCount to show more from memory this.displayedCount += INCREMENT_SIZE; } } @@ -823,6 +1004,12 @@ export default class EntityGrid extends Vue { } this.displayedCount = INITIAL_BATCH_SIZE; this.infiniteScrollReset?.(); + + // For projects: if entities prop is provided, clear internal state + if (this.entityType === "projects" && this.entities) { + this.allProjects = []; + this.loadBeforeId = undefined; + } } /** diff --git a/src/components/EntitySelectionStep.vue b/src/components/EntitySelectionStep.vue index d1ce7a8b..3fba2141 100644 --- a/src/components/EntitySelectionStep.vue +++ b/src/components/EntitySelectionStep.vue @@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */ @@ -95,9 +94,9 @@ export default class EntitySelectionStep extends Vue { @Prop({ default: false }) isFromProjectView!: boolean; - /** Array of available projects */ - @Prop({ required: true }) - projects!: PlanData[]; + /** Array of available projects (optional - EntityGrid loads internally if not provided) */ + @Prop({ required: false }) + projects?: PlanData[]; /** Array of available contacts */ @Prop({ required: true }) @@ -149,10 +148,6 @@ 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 92d3d68c..d5412ef6 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -15,7 +15,6 @@ giverEntityType === 'project' || recipientEntityType === 'project' " :is-from-project-view="isFromProjectView" - :projects="projects" :all-contacts="allContacts" :active-did="activeDid" :all-my-dids="allMyDids" @@ -29,11 +28,6 @@ :unit-code="unitCode" :offer-id="offerId" :notify="$notify" - :load-more-callback=" - giverEntityType === 'project' || recipientEntityType === 'project' - ? handleLoadMoreProjects - : undefined - " @entity-selected="handleEntitySelected" @cancel="cancel" /> @@ -73,7 +67,6 @@ import { createAndSubmitGive, didInfo, serverMessageForUser, - getHeaders, } from "../libs/endorserServer"; import * as libsUtil from "../libs/util"; import { Contact } from "../db/tables/contacts"; @@ -139,7 +132,6 @@ export default class GiftedDialog extends Vue { firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description) giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent offerId = ""; - projects: PlanData[] = []; prompt = ""; receiver?: libsUtil.GiverReceiverInputInfo; stepType = "giver"; @@ -239,16 +231,6 @@ export default class GiftedDialog extends Vue { this.allContacts = await this.$contactsByDateAdded(); this.allMyDids = await retrieveAccountDids(); - - if ( - this.giverEntityType === "project" || - this.recipientEntityType === "project" - ) { - await this.loadProjects(); - } else { - // Clear projects array when not needed - this.projects = []; - } } catch (err: unknown) { logger.error("Error retrieving settings from database:", err); this.safeNotify.error( @@ -494,77 +476,6 @@ export default class GiftedDialog extends Vue { this.firstStep = false; } - /** - * Load projects from the API - * @param beforeId - Optional rowId for pagination (loads projects before this ID) - */ - async loadProjects(beforeId?: string) { - try { - 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), - }); - - if (response.status !== 200) { - throw new Error("Failed to load projects"); - } - - const results = await response.json(); - if (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 a516cdfb..4262334c 100644 --- a/src/components/MeetingProjectDialog.vue +++ b/src/components/MeetingProjectDialog.vue @@ -7,7 +7,6 @@ @@ -58,10 +56,6 @@ export default class MeetingProjectDialog extends Vue { /** Whether the dialog is visible */ visible = false; - /** Array of available projects */ - @Prop({ required: true }) - allProjects!: PlanData[]; - /** Active user's DID */ @Prop({ required: true }) activeDid!: string; @@ -78,10 +72,6 @@ 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 4773a2dc..a3378330 100644 --- a/src/views/OnboardMeetingSetupView.vue +++ b/src/views/OnboardMeetingSetupView.vue @@ -269,12 +269,10 @@ ({ - 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 (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); - } - /** * Computed property for selected project * Returns the separately stored selected project data @@ -977,18 +898,13 @@ export default class OnboardMeetingView extends Vue { } /** - * Handle dialog open event - stop auto-refresh in MembersList and load projects + * Handle dialog open event - stop auto-refresh in MembersList */ - async handleDialogOpen(): Promise { + handleDialogOpen(): void { const membersList = this.$refs.membersList as MembersList; if (membersList) { membersList.stopAutoRefresh(); } - - // Load projects when dialog opens (if not already loaded) - if (this.allProjects.length === 0) { - await this.loadProjects(); - } } /** From 223031866b4236a4ae11479a34395bdbde38329a Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 17 Nov 2025 19:58:55 +0800 Subject: [PATCH 8/8] refactor: remove unused loadMoreCallback prop from EntityGrid Remove loadMoreCallback prop and related backward compatibility code. No parent components were using this prop, and it has been superseded by the internal pagination mechanism using fetchProjects() and beforeId. --- src/components/EntityGrid.vue | 46 ----------------------------------- 1 file changed, 46 deletions(-) diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue index c2ae4fde..1a964d8c 100644 --- a/src/components/EntityGrid.vue +++ b/src/components/EntityGrid.vue @@ -222,7 +222,6 @@ 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 @@ -302,23 +301,6 @@ 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 */ @@ -807,15 +789,6 @@ export default class EntityGrid extends Vue { !!beforeId && !this.isLoadingProjects; - // Also check if loadMoreCallback is provided (for backward compatibility) - if ( - this.entities && - this.displayedCount >= this.entities.length && - this.loadMoreCallback - ) { - return !this.isLoadingMore; - } - return hasMoreLoaded || canLoadMoreFromServer; } @@ -928,25 +901,6 @@ export default class EntityGrid extends Vue { } finally { this.isLoadingProjects = false; } - } else if ( - this.entities && - this.displayedCount >= this.entities.length && - this.loadMoreCallback && - !this.isLoadingMore - ) { - // Backward compatibility: use loadMoreCallback if provided - 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 - logger.error("Error in loadMoreCallback:", error); - } finally { - this.isLoadingMore = false; - } } else { // Normal case: increment displayedCount to show more from memory this.displayedCount += INCREMENT_SIZE;