forked from trent_larson/crowd-funder-for-time-pwa
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.
1029 lines
32 KiB
Vue
1029 lines
32 KiB
Vue
/** * EntityGrid.vue - Unified entity grid layout component * * Extracted from
|
|
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
|
projects, and special entities with selection. * * @author Matthew Raymer */
|
|
<template>
|
|
<!-- Quick Search -->
|
|
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
|
|
<input
|
|
v-model="searchTerm"
|
|
type="text"
|
|
placeholder="Search…"
|
|
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
|
|
@input="handleSearchInput"
|
|
@keydown.enter="performSearch"
|
|
/>
|
|
<div
|
|
v-show="isSearching && searchTerm"
|
|
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
|
|
>
|
|
<font-awesome
|
|
icon="spinner"
|
|
class="fa-spin-pulse leading-[1.1]"
|
|
></font-awesome>
|
|
</div>
|
|
<button
|
|
:disabled="!searchTerm"
|
|
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
|
|
@click="clearSearch"
|
|
>
|
|
<font-awesome
|
|
:icon="searchTerm ? 'times' : 'magnifying-glass'"
|
|
class="fa-fw"
|
|
></font-awesome>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
|
|
class="mb-4 text-sm italic text-slate-500 text-center"
|
|
>
|
|
“{{ searchTerm }}” doesn't match any
|
|
{{ entityType === "people" ? "people" : "projects" }}. Try a different
|
|
search.
|
|
</div>
|
|
|
|
<ul
|
|
ref="scrollContainer"
|
|
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
|
|
>
|
|
<!-- Special entities (You, Unnamed) for people grids -->
|
|
<template v-if="entityType === 'people'">
|
|
<!-- "You" entity -->
|
|
<SpecialEntityCard
|
|
v-if="showYouEntity && !searchTerm.trim()"
|
|
entity-type="you"
|
|
label="You"
|
|
icon="hand"
|
|
:selectable="youSelectable"
|
|
:conflicted="youConflicted"
|
|
:entity-data="youEntityData"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@entity-selected="handleEntitySelected"
|
|
/>
|
|
|
|
<!-- "Unnamed" entity -->
|
|
<SpecialEntityCard
|
|
v-if="showUnnamedEntity && !searchTerm.trim()"
|
|
entity-type="unnamed"
|
|
:label="unnamedEntityName"
|
|
icon="circle-question"
|
|
:entity-data="unnamedEntityData"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@entity-selected="handleEntitySelected"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Empty state message -->
|
|
<li v-if="hasNoEntities" :class="emptyStateClasses">
|
|
{{ emptyStateMessage }}
|
|
</li>
|
|
|
|
<!-- Entity cards (people or projects) -->
|
|
<template v-if="entityType === 'people'">
|
|
<!-- When showing contacts without search: split into recent and alphabetical -->
|
|
<template v-if="!searchTerm.trim()">
|
|
<!-- Recently Added Section -->
|
|
<template v-if="recentContacts.length > 0">
|
|
<li
|
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
|
>
|
|
Recently Added
|
|
</li>
|
|
<PersonCard
|
|
v-for="person in recentContacts"
|
|
:key="person.did"
|
|
:person="person"
|
|
:conflicted="isPersonConflicted(person.did)"
|
|
:show-time-icon="true"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@person-selected="handlePersonSelected"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Alphabetical Section -->
|
|
<template v-if="alphabeticalContacts.length > 0">
|
|
<li
|
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
|
>
|
|
Everyone
|
|
</li>
|
|
<PersonCard
|
|
v-for="person in alphabeticalContacts"
|
|
:key="person.did"
|
|
:person="person"
|
|
:conflicted="isPersonConflicted(person.did)"
|
|
:show-time-icon="true"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@person-selected="handlePersonSelected"
|
|
/>
|
|
</template>
|
|
</template>
|
|
|
|
<!-- When searching: show filtered results normally -->
|
|
<template v-else>
|
|
<PersonCard
|
|
v-for="person in displayedEntities as Contact[]"
|
|
:key="person.did"
|
|
:person="person"
|
|
:conflicted="isPersonConflicted(person.did)"
|
|
:show-time-icon="true"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@person-selected="handlePersonSelected"
|
|
/>
|
|
</template>
|
|
</template>
|
|
|
|
<template v-else-if="entityType === 'projects'">
|
|
<ProjectCard
|
|
v-for="project in displayedEntities as PlanData[]"
|
|
:key="project.handleId"
|
|
:project="project"
|
|
:active-did="activeDid"
|
|
:all-my-dids="allMyDids"
|
|
:all-contacts="allContacts"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@project-selected="handleProjectSelected"
|
|
/>
|
|
</template>
|
|
</ul>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
|
|
import { useInfiniteScroll } from "@vueuse/core";
|
|
import PersonCard from "./PersonCard.vue";
|
|
import ProjectCard from "./ProjectCard.vue";
|
|
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
|
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
|
|
*/
|
|
const INITIAL_BATCH_SIZE = 20;
|
|
const INCREMENT_SIZE = 20;
|
|
const RECENT_CONTACTS_COUNT = 3;
|
|
|
|
/**
|
|
* EntityGrid - Unified grid layout for displaying people or projects
|
|
*
|
|
* Features:
|
|
* - Responsive grid layout for people/projects
|
|
* - Special entity integration (You, Unnamed)
|
|
* - Conflict detection integration
|
|
* - Empty state messaging
|
|
* - Event delegation for entity selection
|
|
* - Warning notifications for conflicted entities
|
|
* - Template streamlined with computed CSS properties
|
|
* - Configurable entity display logic via function props
|
|
*/
|
|
@Component({
|
|
components: {
|
|
PersonCard,
|
|
ProjectCard,
|
|
SpecialEntityCard,
|
|
},
|
|
mixins: [PlatformServiceMixin],
|
|
})
|
|
export default class EntityGrid extends Vue {
|
|
/** Type of entities to display */
|
|
@Prop({ required: true })
|
|
entityType!: "people" | "projects";
|
|
|
|
// Search state
|
|
searchTerm = "";
|
|
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;
|
|
infiniteScrollReset?: () => void;
|
|
scrollContainer?: HTMLElement;
|
|
isLoadingMore = false; // Prevent duplicate callback calls
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
|
|
* projects internally from the API server. If provided, uses the provided list.
|
|
*/
|
|
@Prop({ required: false })
|
|
entities?: Contact[] | PlanData[];
|
|
|
|
/** Active user's DID */
|
|
@Prop({ required: true })
|
|
activeDid!: string;
|
|
|
|
/** All user's DIDs */
|
|
@Prop({ required: true })
|
|
allMyDids!: string[];
|
|
|
|
/** All contacts */
|
|
@Prop({ required: true })
|
|
allContacts!: Contact[];
|
|
|
|
/** Function to check if a person DID would create a conflict */
|
|
@Prop({ required: true })
|
|
conflictChecker!: (did: string) => boolean;
|
|
|
|
/** Whether to show the "You" entity for people grids */
|
|
@Prop({ default: true })
|
|
showYouEntity!: boolean;
|
|
|
|
/** Whether to show the "Unnamed" entity for people grids */
|
|
@Prop({ default: true })
|
|
showUnnamedEntity!: boolean;
|
|
|
|
/** Whether the "You" entity is selectable */
|
|
@Prop({ default: true })
|
|
youSelectable!: boolean;
|
|
|
|
/** Notification function from parent component */
|
|
@Prop()
|
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
|
|
|
/** Context for conflict messages (e.g., "giver", "recipient") */
|
|
@Prop({ default: "other party" })
|
|
conflictContext!: string;
|
|
|
|
/**
|
|
* Function to determine which entities to display (allows parent control)
|
|
*
|
|
* This function prop allows parent components to customize which entities
|
|
* are displayed in the grid, enabling advanced filtering and sorting.
|
|
* Note: Infinite scroll is disabled when this prop is provided.
|
|
*
|
|
* @param entities - The full array of entities (Contact[] or PlanData[])
|
|
* @param entityType - The type of entities being displayed ("people" or "projects")
|
|
* @returns Filtered/sorted array of entities to display
|
|
*
|
|
* @example
|
|
* // Custom filtering: only show contacts with profile images
|
|
* :display-entities-function="(entities, type) =>
|
|
* entities.filter(e => e.profileImageUrl)"
|
|
*
|
|
* @example
|
|
* // Custom sorting: sort projects by name
|
|
* :display-entities-function="(entities, type) =>
|
|
* entities.sort((a, b) => a.name.localeCompare(b.name))"
|
|
*/
|
|
@Prop({ default: null })
|
|
displayEntitiesFunction?: (
|
|
entities: Contact[] | PlanData[],
|
|
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<void>;
|
|
|
|
/**
|
|
* CSS classes for the empty state message
|
|
*/
|
|
get emptyStateClasses(): string {
|
|
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
|
|
*/
|
|
get displayedEntities(): Contact[] | PlanData[] {
|
|
// If searching, return filtered results with infinite scroll
|
|
if (this.searchTerm.trim()) {
|
|
return this.filteredEntities.slice(0, this.displayedCount);
|
|
}
|
|
|
|
// If custom function provided, use it (disables infinite scroll)
|
|
if (this.displayEntitiesFunction) {
|
|
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
|
}
|
|
|
|
// Default: projects use infinite scroll
|
|
if (this.entityType === "projects") {
|
|
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
|
}
|
|
|
|
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get the most recently added contacts (when showing contacts and not searching)
|
|
*
|
|
* NOTE: This assumes entities are already sorted by date added (newest first).
|
|
* See the entities prop documentation for details on using $contactsByDateAdded().
|
|
*/
|
|
get recentContacts(): Contact[] {
|
|
if (
|
|
this.entityType !== "people" ||
|
|
this.searchTerm.trim() ||
|
|
!this.entities
|
|
) {
|
|
return [];
|
|
}
|
|
// Entities are already sorted by date added (newest first)
|
|
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
|
|
}
|
|
|
|
/**
|
|
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
|
|
* Uses infinite scroll to control how many are displayed
|
|
*/
|
|
get alphabeticalContacts(): Contact[] {
|
|
if (
|
|
this.entityType !== "people" ||
|
|
this.searchTerm.trim() ||
|
|
!this.entities
|
|
) {
|
|
return [];
|
|
}
|
|
// Skip the first few (recent contacts) and sort the rest alphabetically
|
|
// Create a copy to avoid mutating the original array
|
|
const remaining = this.entities as Contact[];
|
|
const sorted = [...remaining].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);
|
|
});
|
|
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
|
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
|
return sorted.slice(0, toShow);
|
|
}
|
|
|
|
/**
|
|
* Computed empty state message based on entity type
|
|
*/
|
|
get emptyStateMessage(): string {
|
|
if (this.entityType === "projects") {
|
|
return "(No projects found.)";
|
|
} else {
|
|
return "(Add friends to see more people worthy of recognition.)";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the "You" entity is conflicted
|
|
*/
|
|
get youConflicted(): boolean {
|
|
return this.conflictChecker(this.activeDid);
|
|
}
|
|
|
|
/**
|
|
* Entity data for the "You" special entity
|
|
*/
|
|
get youEntityData(): { did: string; name: string } {
|
|
return {
|
|
did: this.activeDid,
|
|
name: "You",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Entity data for the "Unnamed" special entity
|
|
*/
|
|
get unnamedEntityData(): { did: string; name: string } {
|
|
return {
|
|
did: "",
|
|
name: UNNAMED_ENTITY_NAME,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the unnamed entity name constant
|
|
*/
|
|
get unnamedEntityName(): string {
|
|
return UNNAMED_ENTITY_NAME;
|
|
}
|
|
|
|
/**
|
|
* Check if a person DID is conflicted
|
|
*/
|
|
isPersonConflicted(did: string): boolean {
|
|
return this.conflictChecker(did);
|
|
}
|
|
|
|
/**
|
|
* Handle person selection from PersonCard
|
|
*/
|
|
handlePersonSelected(person: Contact): void {
|
|
this.emitEntitySelected({
|
|
type: "person",
|
|
data: person,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle project selection from ProjectCard
|
|
*/
|
|
handleProjectSelected(project: PlanData): void {
|
|
this.emitEntitySelected({
|
|
type: "project",
|
|
data: project,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle special entity selection from SpecialEntityCard
|
|
* Treat "You" and "Unnamed" as person entities
|
|
*/
|
|
handleEntitySelected(event: { data: { did?: string; name: string } }): void {
|
|
// Convert special entities to person entities since they represent people
|
|
this.emitEntitySelected({
|
|
type: "person",
|
|
data: event.data as Contact,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle search input with debouncing
|
|
*/
|
|
handleSearchInput(): void {
|
|
// Show spinner immediately when user types
|
|
this.isSearching = true;
|
|
|
|
// Clear existing timeout
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
|
|
// Set new timeout for 500ms delay
|
|
this.searchTimeout = setTimeout(() => {
|
|
this.performSearch();
|
|
}, 500);
|
|
}
|
|
|
|
/**
|
|
* Perform the actual search
|
|
* Routes to server-side search for projects or client-side filtering for contacts
|
|
*/
|
|
async performSearch(): Promise<void> {
|
|
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 {
|
|
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 {
|
|
// Client-side filtering for contacts (complete list)
|
|
await this.performContactSearch();
|
|
}
|
|
|
|
// Reset displayed count when search completes
|
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
|
this.infiniteScrollReset?.();
|
|
} finally {
|
|
this.isSearching = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<void> {
|
|
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
|
|
*/
|
|
clearSearch(): void {
|
|
this.searchTerm = "";
|
|
this.filteredEntities = [];
|
|
this.isSearching = false;
|
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
|
this.searchBeforeId = undefined;
|
|
this.infiniteScrollReset?.();
|
|
|
|
// Clear any pending timeout
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if more entities can be loaded
|
|
*/
|
|
canLoadMore(): boolean {
|
|
if (this.displayEntitiesFunction) {
|
|
// Custom function disables infinite scroll
|
|
return false;
|
|
}
|
|
|
|
if (this.searchTerm.trim()) {
|
|
// 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 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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Initialize infinite scroll on mount
|
|
*/
|
|
async mounted(): Promise<void> {
|
|
// 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,
|
|
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 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 {
|
|
// People: increment displayedCount to show more from memory
|
|
this.displayedCount += INCREMENT_SIZE;
|
|
}
|
|
}
|
|
},
|
|
{
|
|
distance: 50, // pixels from bottom
|
|
canLoadMore: () => this.canLoadMore(),
|
|
},
|
|
);
|
|
this.infiniteScrollReset = reset;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Emit methods using @Emit decorator
|
|
|
|
@Emit("entity-selected")
|
|
emitEntitySelected(data: {
|
|
type: "person" | "project";
|
|
data: Contact | PlanData;
|
|
}): {
|
|
type: "person" | "project";
|
|
data: Contact | PlanData;
|
|
} {
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* 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 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup timeouts when component is destroyed
|
|
*/
|
|
beforeUnmount(): void {
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Grid-specific styles if needed */
|
|
</style>
|