|
|
@ -42,7 +42,10 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
search. |
|
|
search. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
|
|
|
<ul class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"> |
|
|
<ul |
|
|
|
|
|
ref="scrollContainer" |
|
|
|
|
|
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto" |
|
|
|
|
|
> |
|
|
<!-- Special entities (You, Unnamed) for people grids --> |
|
|
<!-- Special entities (You, Unnamed) for people grids --> |
|
|
<template v-if="entityType === 'people'"> |
|
|
<template v-if="entityType === 'people'"> |
|
|
<!-- "You" entity --> |
|
|
<!-- "You" entity --> |
|
|
@ -152,7 +155,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<script lang="ts"> |
|
|
<script lang="ts"> |
|
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator"; |
|
|
import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator"; |
|
|
|
|
|
import { useInfiniteScroll } from "@vueuse/core"; |
|
|
import PersonCard from "./PersonCard.vue"; |
|
|
import PersonCard from "./PersonCard.vue"; |
|
|
import ProjectCard from "./ProjectCard.vue"; |
|
|
import ProjectCard from "./ProjectCard.vue"; |
|
|
import SpecialEntityCard from "./SpecialEntityCard.vue"; |
|
|
import SpecialEntityCard from "./SpecialEntityCard.vue"; |
|
|
@ -161,6 +165,13 @@ import { PlanData } from "../interfaces/records"; |
|
|
import { NotificationIface } from "../constants/app"; |
|
|
import { NotificationIface } from "../constants/app"; |
|
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; |
|
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 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 |
|
|
* EntityGrid - Unified grid layout for displaying people or projects |
|
|
* |
|
|
* |
|
|
@ -192,14 +203,15 @@ export default class EntityGrid extends Vue { |
|
|
searchTimeout: NodeJS.Timeout | null = null; |
|
|
searchTimeout: NodeJS.Timeout | null = null; |
|
|
filteredEntities: Contact[] | PlanData[] = []; |
|
|
filteredEntities: Contact[] | PlanData[] = []; |
|
|
|
|
|
|
|
|
|
|
|
// Infinite scroll state |
|
|
|
|
|
displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
infiniteScrollReset?: () => void; |
|
|
|
|
|
scrollContainer?: HTMLElement; |
|
|
|
|
|
|
|
|
/** Array of entities to display */ |
|
|
/** Array of entities to display */ |
|
|
@Prop({ required: true }) |
|
|
@Prop({ required: true }) |
|
|
entities!: Contact[] | PlanData[]; |
|
|
entities!: Contact[] | PlanData[]; |
|
|
|
|
|
|
|
|
/** Maximum number of entities to display */ |
|
|
|
|
|
@Prop({ default: 10 }) |
|
|
|
|
|
maxItems!: number; |
|
|
|
|
|
|
|
|
|
|
|
/** Active user's DID */ |
|
|
/** Active user's DID */ |
|
|
@Prop({ required: true }) |
|
|
@Prop({ required: true }) |
|
|
activeDid!: string; |
|
|
activeDid!: string; |
|
|
@ -240,34 +252,27 @@ export default class EntityGrid extends Vue { |
|
|
* Function to determine which entities to display (allows parent control) |
|
|
* Function to determine which entities to display (allows parent control) |
|
|
* |
|
|
* |
|
|
* This function prop allows parent components to customize which entities |
|
|
* This function prop allows parent components to customize which entities |
|
|
* are displayed in the grid, enabling advanced filtering, sorting, and |
|
|
* are displayed in the grid, enabling advanced filtering and sorting. |
|
|
* display logic beyond the default simple slice behavior. |
|
|
* Note: Infinite scroll is disabled when this prop is provided. |
|
|
* |
|
|
* |
|
|
* @param entities - The full array of entities (Contact[] or PlanData[]) |
|
|
* @param entities - The full array of entities (Contact[] or PlanData[]) |
|
|
* @param entityType - The type of entities being displayed ("people" or "projects") |
|
|
* @param entityType - The type of entities being displayed ("people" or "projects") |
|
|
* @param maxItems - The maximum number of items to display (from maxItems prop) |
|
|
|
|
|
* @returns Filtered/sorted array of entities to display |
|
|
* @returns Filtered/sorted array of entities to display |
|
|
* |
|
|
* |
|
|
* @example |
|
|
* @example |
|
|
* // Custom filtering: only show contacts with profile images |
|
|
* // Custom filtering: only show contacts with profile images |
|
|
* :display-entities-function="(entities, type, max) => |
|
|
* :display-entities-function="(entities, type) => |
|
|
* entities.filter(e => e.profileImageUrl).slice(0, max)" |
|
|
* entities.filter(e => e.profileImageUrl)" |
|
|
* |
|
|
* |
|
|
* @example |
|
|
* @example |
|
|
* // Custom sorting: sort projects by name |
|
|
* // Custom sorting: sort projects by name |
|
|
* :display-entities-function="(entities, type, max) => |
|
|
* :display-entities-function="(entities, type) => |
|
|
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)" |
|
|
* entities.sort((a, b) => a.name.localeCompare(b.name))" |
|
|
* |
|
|
|
|
|
* @example |
|
|
|
|
|
* // Advanced logic: different limits for different entity types |
|
|
|
|
|
* :display-entities-function="(entities, type, max) => |
|
|
|
|
|
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)" |
|
|
|
|
|
*/ |
|
|
*/ |
|
|
@Prop({ default: null }) |
|
|
@Prop({ default: null }) |
|
|
displayEntitiesFunction?: ( |
|
|
displayEntitiesFunction?: ( |
|
|
entities: Contact[] | PlanData[], |
|
|
entities: Contact[] | PlanData[], |
|
|
entityType: "people" | "projects", |
|
|
entityType: "people" | "projects", |
|
|
maxItems: number, |
|
|
|
|
|
) => Contact[] | PlanData[]; |
|
|
) => Contact[] | PlanData[]; |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -278,27 +283,27 @@ export default class EntityGrid extends Vue { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Computed entities to display - uses function prop if provided, otherwise defaults |
|
|
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll |
|
|
* When searching, returns filtered results instead of original logic |
|
|
* When searching, returns filtered results with infinite scroll applied |
|
|
*/ |
|
|
*/ |
|
|
get displayedEntities(): Contact[] | PlanData[] { |
|
|
get displayedEntities(): Contact[] | PlanData[] { |
|
|
// If searching, return filtered results |
|
|
// If searching, return filtered results with infinite scroll |
|
|
if (this.searchTerm.trim()) { |
|
|
if (this.searchTerm.trim()) { |
|
|
return this.filteredEntities; |
|
|
return this.filteredEntities.slice(0, this.displayedCount); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Original logic when not searching |
|
|
// If custom function provided, use it (disables infinite scroll) |
|
|
if (this.displayEntitiesFunction) { |
|
|
if (this.displayEntitiesFunction) { |
|
|
return this.displayEntitiesFunction( |
|
|
return this.displayEntitiesFunction(this.entities, this.entityType); |
|
|
this.entities, |
|
|
} |
|
|
this.entityType, |
|
|
|
|
|
this.maxItems, |
|
|
// Default: projects use infinite scroll |
|
|
); |
|
|
if (this.entityType === "projects") { |
|
|
|
|
|
return (this.entities as PlanData[]).slice(0, this.displayedCount); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Default implementation for backward compatibility |
|
|
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount) |
|
|
const maxDisplay = this.entityType === "projects" ? 10 : this.maxItems; |
|
|
return []; |
|
|
return this.entities.slice(0, maxDisplay); |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -314,6 +319,7 @@ export default class EntityGrid extends Vue { |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching) |
|
|
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching) |
|
|
|
|
|
* Uses infinite scroll to control how many are displayed |
|
|
*/ |
|
|
*/ |
|
|
get alphabeticalContacts(): Contact[] { |
|
|
get alphabeticalContacts(): Contact[] { |
|
|
if (this.entityType !== "people" || this.searchTerm.trim()) { |
|
|
if (this.entityType !== "people" || this.searchTerm.trim()) { |
|
|
@ -321,13 +327,16 @@ export default class EntityGrid extends Vue { |
|
|
} |
|
|
} |
|
|
// Skip the first 3 (recent contacts) and sort the rest alphabetically |
|
|
// Skip the first 3 (recent contacts) and sort the rest alphabetically |
|
|
// Create a copy to avoid mutating the original array |
|
|
// Create a copy to avoid mutating the original array |
|
|
const remaining = (this.entities as Contact[]).slice(3); |
|
|
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT); |
|
|
return [...remaining].sort((a: Contact, b: Contact) => { |
|
|
const sorted = [...remaining].sort((a: Contact, b: Contact) => { |
|
|
// Sort alphabetically by name, falling back to DID if name is missing |
|
|
// Sort alphabetically by name, falling back to DID if name is missing |
|
|
const nameA = (a.name || a.did).toLowerCase(); |
|
|
const nameA = (a.name || a.did).toLowerCase(); |
|
|
const nameB = (b.name || b.did).toLowerCase(); |
|
|
const nameB = (b.name || b.did).toLowerCase(); |
|
|
return nameA.localeCompare(nameB); |
|
|
return nameA.localeCompare(nameB); |
|
|
}); |
|
|
}); |
|
|
|
|
|
// Apply infinite scroll: show based on displayedCount (minus the 3 recent) |
|
|
|
|
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT); |
|
|
|
|
|
return sorted.slice(0, toShow); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -438,6 +447,8 @@ export default class EntityGrid extends Vue { |
|
|
async performSearch(): Promise<void> { |
|
|
async performSearch(): Promise<void> { |
|
|
if (!this.searchTerm.trim()) { |
|
|
if (!this.searchTerm.trim()) { |
|
|
this.filteredEntities = []; |
|
|
this.filteredEntities = []; |
|
|
|
|
|
this.displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
this.infiniteScrollReset?.(); |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -474,6 +485,10 @@ export default class EntityGrid extends Vue { |
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); |
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Reset displayed count when search completes |
|
|
|
|
|
this.displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
this.infiniteScrollReset?.(); |
|
|
} finally { |
|
|
} finally { |
|
|
this.isSearching = false; |
|
|
this.isSearching = false; |
|
|
} |
|
|
} |
|
|
@ -486,6 +501,8 @@ export default class EntityGrid extends Vue { |
|
|
this.searchTerm = ""; |
|
|
this.searchTerm = ""; |
|
|
this.filteredEntities = []; |
|
|
this.filteredEntities = []; |
|
|
this.isSearching = false; |
|
|
this.isSearching = false; |
|
|
|
|
|
this.displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
this.infiniteScrollReset?.(); |
|
|
|
|
|
|
|
|
// Clear any pending timeout |
|
|
// Clear any pending timeout |
|
|
if (this.searchTimeout) { |
|
|
if (this.searchTimeout) { |
|
|
@ -494,6 +511,56 @@ export default class EntityGrid extends Vue { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 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 filtered entities |
|
|
|
|
|
return this.displayedCount < this.filteredEntities.length; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
if (this.entityType === "projects") { |
|
|
|
|
|
// Projects: check if more available |
|
|
|
|
|
return this.displayedCount < this.entities.length; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// People: check if more alphabetical contacts available |
|
|
|
|
|
// Total available = 3 recent + all alphabetical |
|
|
|
|
|
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT); |
|
|
|
|
|
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length; |
|
|
|
|
|
return this.displayedCount < totalAvailable; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* Initialize infinite scroll on mount |
|
|
|
|
|
*/ |
|
|
|
|
|
mounted(): void { |
|
|
|
|
|
this.$nextTick(() => { |
|
|
|
|
|
const container = this.$refs.scrollContainer as HTMLElement; |
|
|
|
|
|
|
|
|
|
|
|
if (container) { |
|
|
|
|
|
const { reset } = useInfiniteScroll( |
|
|
|
|
|
container, |
|
|
|
|
|
() => { |
|
|
|
|
|
// Load more: increment displayedCount |
|
|
|
|
|
this.displayedCount += INCREMENT_SIZE; |
|
|
|
|
|
}, |
|
|
|
|
|
{ |
|
|
|
|
|
distance: 50, // pixels from bottom |
|
|
|
|
|
canLoadMore: () => this.canLoadMore(), |
|
|
|
|
|
}, |
|
|
|
|
|
); |
|
|
|
|
|
this.infiniteScrollReset = reset; |
|
|
|
|
|
} |
|
|
|
|
|
}); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
// Emit methods using @Emit decorator |
|
|
// Emit methods using @Emit decorator |
|
|
|
|
|
|
|
|
@Emit("entity-selected") |
|
|
@Emit("entity-selected") |
|
|
@ -507,6 +574,24 @@ export default class EntityGrid extends Vue { |
|
|
return data; |
|
|
return data; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* Watch for changes in search term to reset displayed count |
|
|
|
|
|
*/ |
|
|
|
|
|
@Watch("searchTerm") |
|
|
|
|
|
onSearchTermChange(): void { |
|
|
|
|
|
this.displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
this.infiniteScrollReset?.(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* Watch for changes in entities prop to reset displayed count |
|
|
|
|
|
*/ |
|
|
|
|
|
@Watch("entities") |
|
|
|
|
|
onEntitiesChange(): void { |
|
|
|
|
|
this.displayedCount = INITIAL_BATCH_SIZE; |
|
|
|
|
|
this.infiniteScrollReset?.(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Cleanup timeouts when component is destroyed |
|
|
* Cleanup timeouts when component is destroyed |
|
|
*/ |
|
|
*/ |
|
|
|