From cb75b25529ef0780e16c380956a563074b99877f Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 17 Nov 2025 19:49:17 +0800 Subject: [PATCH] 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 e79bc79999..c2ae4fde82 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 d1ce7a8b99..3fba214129 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 92d3d68c3b..d5412ef653 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 a516cdfb60..4262334c53 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 4773a2dc59..a337833099 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(); - } } /**