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();
- }
}
/**