Remove loadMoreCallback prop and related backward compatibility code. No parent components were using this prop, and it has been superseded by the internal pagination mechanism using fetchProjects() and beforeId.
983 lines
30 KiB
Vue
983 lines
30 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;
|
|
|
|
/**
|
|
* 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[];
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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 {
|
|
// 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>
|