diff --git a/src/components/EntityGrid.vue b/src/components/EntityGrid.vue
index b85e6d09..1a964d8c 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 }}
@@ -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,16 @@ 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 = "";
+
+ // 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;
@@ -211,17 +226,17 @@ export default class EntityGrid extends Vue {
/**
* Array of entities to display
*
+ * 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.
- * 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 (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 })
@@ -293,6 +308,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
@@ -305,12 +347,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)
@@ -324,7 +366,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)
@@ -336,7 +382,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
@@ -457,47 +507,28 @@ 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)
+ const searchLower = this.searchTerm.toLowerCase().trim();
+ await this.fetchProjects(undefined, searchLower);
} else {
- this.filteredEntities = (this.entities as PlanData[])
- .filter((project: PlanData) => {
- const name = project.name?.toLowerCase() || "";
- const handleId = project.handleId.toLowerCase();
- return name.includes(searchLower) || handleId.includes(searchLower);
- })
- .sort((a: PlanData, b: PlanData) => {
- // Sort alphabetically by name
- return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
- });
+ // Client-side filtering for contacts (complete list)
+ await this.performContactSearch();
}
// Reset displayed count when search completes
@@ -508,6 +539,194 @@ export default class EntityGrid extends Vue {
}
}
+ /**
+ * 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 fetchProjects(
+ beforeId?: string,
+ claimContents?: string,
+ ): Promise {
+ if (!this.apiServer) {
+ if (claimContents) {
+ this.filteredEntities = [];
+ } else {
+ this.allProjects = [];
+ }
+ if (this.notify) {
+ this.notify(
+ {
+ group: "alert",
+ type: "danger",
+ title: "Error",
+ text: "API server not configured",
+ },
+ TIMEOUTS.SHORT,
+ );
+ }
+ return;
+ }
+
+ 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) {
+ params.push(`beforeId=${encodeURIComponent(beforeId)}`);
+ }
+ if (params.length > 0) {
+ url += `?${params.join("&")}`;
+ }
+
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ headers: await getHeaders(this.activeDid),
+ });
+
+ if (response.status !== 200) {
+ throw new Error(
+ isSearch ? "Failed to search projects" : "Failed to load projects",
+ );
+ }
+
+ const results = await response.json();
+ if (results.data) {
+ const newProjects = results.data.map(
+ (plan: PlanData & { rowId?: string }) => ({
+ ...plan,
+ rowId: plan.rowId,
+ }),
+ );
+
+ 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
+ if (newProjects.length > 0) {
+ const lastProject = newProjects[newProjects.length - 1];
+ this.searchBeforeId = lastProject.rowId || undefined;
+ } else {
+ this.searchBeforeId = undefined; // No more results
+ }
+ } else {
+ // 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 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;
+ }
+ if (this.notify) {
+ this.notify(
+ {
+ group: "alert",
+ type: "danger",
+ title: "Error",
+ text: isSearch
+ ? "Failed to search projects. Please try again."
+ : "Failed to load projects. Please try again.",
+ },
+ TIMEOUTS.STANDARD,
+ );
+ }
+ }
+ }
+
+ /**
+ * Client-side contact search
+ * 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));
+
+ const searchLower = this.searchTerm.toLowerCase().trim();
+
+ this.filteredEntities = (this.entities as Contact[])
+ .filter((contact: Contact) => {
+ const name = contact.name?.toLowerCase() || "";
+ const did = contact.did.toLowerCase();
+ return name.includes(searchLower) || did.includes(searchLower);
+ })
+ .sort((a: Contact, b: Contact) => {
+ // Sort alphabetically by name, falling back to DID if name is missing
+ const nameA = (a.name || a.did).toLowerCase();
+ const nameB = (b.name || b.did).toLowerCase();
+ return nameA.localeCompare(nameB);
+ });
+
+ // Contacts don't need pagination (complete list)
+ this.searchBeforeId = undefined;
+ }
+
/**
* Clear the search
*/
@@ -516,6 +735,7 @@ export default class EntityGrid extends Vue {
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
+ this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// Clear any pending timeout
@@ -535,17 +755,48 @@ 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
if (this.entityType === "projects") {
- // Projects: check if more available
- return this.displayedCount < this.entities.length;
+ // 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;
+
+ 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;
}
@@ -553,16 +804,112 @@ export default class EntityGrid extends Vue {
/**
* Initialize infinite scroll on mount
*/
- mounted(): void {
+ async mounted(): Promise {
+ // 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(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
- () => {
- // Load more: increment displayedCount
- this.displayedCount += INCREMENT_SIZE;
+ async () => {
+ // Search mode: handle search pagination
+ if (this.searchTerm.trim()) {
+ if (this.entityType === "projects") {
+ // Projects: load more search results if available
+ if (
+ this.displayedCount >= this.filteredEntities.length &&
+ this.searchBeforeId &&
+ !this.isLoadingSearchMore
+ ) {
+ this.isLoadingSearchMore = true;
+ try {
+ 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 fetchProjects
+ } finally {
+ this.isLoadingSearchMore = false;
+ }
+ } else {
+ // Show more from already-loaded search results
+ this.displayedCount += INCREMENT_SIZE;
+ }
+ } else {
+ // Contacts: show more from already-filtered results
+ this.displayedCount += INCREMENT_SIZE;
+ }
+ } else {
+ // Non-search mode
+ 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 {
+ // Normal case: increment displayedCount to show more from memory
+ this.displayedCount += INCREMENT_SIZE;
+ }
+ } else {
+ // People: increment displayedCount to show more from memory
+ this.displayedCount += INCREMENT_SIZE;
+ }
+ }
},
{
distance: 50, // pixels from bottom
@@ -588,21 +935,35 @@ 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?.();
+
+ // 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 fd62ccdd..3fba2141 100644
--- a/src/components/EntitySelectionStep.vue
+++ b/src/components/EntitySelectionStep.vue
@@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */
+
+
+
+
Select Project
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ProjectCard.vue b/src/components/ProjectCard.vue
index 77d2aa34..4b995a21 100644
--- a/src/components/ProjectCard.vue
+++ b/src/components/ProjectCard.vue
@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
>
diff --git a/src/interfaces/records.ts b/src/interfaces/records.ts
index 03627904..ff2a0dec 100644
--- a/src/interfaces/records.ts
+++ b/src/interfaces/records.ts
@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
-// a summary record; the VC is not currently part of this record
+/**
+ * A summary record
+ * The VC is not currently part of this record.
+ *
+ * If you change this, you may want to update NewActivityView.vue to handle differences correctly.
+ */
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
- wrappedClaimBefore: GenericCredWrapper;
+ // This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
+ // The endorser-ch test code shows some cases.
+ wrappedClaimBefore?: GenericCredWrapper;
}
/**
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index 718a731f..3d343e6d 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
- this.numNewStarredProjectChanges = starredProjectChanges.data.length;
+ // filter out any data elements where there is no wrappedClaimBefore
+ const filteredNewStarredProjectChanges =
+ starredProjectChanges.data.filter(
+ (change) => change.wrappedClaimBefore !== undefined,
+ );
+ this.numNewStarredProjectChanges =
+ filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue
index 6dff8909..cbaa5cfd 100644
--- a/src/views/NewActivityView.vue
+++ b/src/views/NewActivityView.vue
@@ -284,7 +284,10 @@
-
The changes did not affect essential project data.
+
+ The changes are not important, like it was saved by accident or
+ you've seen it all before.
+
=
+ const wrappedClaim: GenericCredWrapper | undefined =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
- let previousClaim: PlanActionClaim;
+ let previousClaim: PlanActionClaim | undefined;
- const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
+ const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
previousClaim = embeddedClaim;
}
- if (!previousClaim || !currentPlan.handleId) {
+ if (!previousClaim) {
+ // Can happen when a project is starred after the stored last-seen-change-jwt ID
+ // so we'll just leave the message saying there are no important differences.
continue;
}
diff --git a/src/views/OnboardMeetingSetupView.vue b/src/views/OnboardMeetingSetupView.vue
index 33d345f0..a3378330 100644
--- a/src/views/OnboardMeetingSetupView.vue
+++ b/src/views/OnboardMeetingSetupView.vue
@@ -186,16 +186,59 @@