@ -42,7 +42,10 @@ projects, and special entities with selection. * * @author Matthew Raymer */
search .
< / 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 -- >
< template v-if ="entityType === 'people'" >
<!-- "You" entity -- >
@ -152,7 +155,8 @@ projects, and special entities with selection. * * @author Matthew Raymer */
< / template >
< 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 ProjectCard from "./ProjectCard.vue" ;
import SpecialEntityCard from "./SpecialEntityCard.vue" ;
@ -161,6 +165,13 @@ import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app" ;
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
*
@ -192,14 +203,15 @@ export default class EntityGrid extends Vue {
searchTimeout : NodeJS . Timeout | null = null ;
filteredEntities : Contact [ ] | PlanData [ ] = [ ] ;
/ / I n f i n i t e s c r o l l s t a t e
displayedCount = INITIAL_BATCH_SIZE ;
infiniteScrollReset ? : ( ) => void ;
scrollContainer ? : HTMLElement ;
/** Array of entities to display */
@ Prop ( { required : true } )
entities ! : Contact [ ] | PlanData [ ] ;
/** Maximum number of entities to display */
@ Prop ( { default : 10 } )
maxItems ! : number ;
/** Active user's DID */
@ Prop ( { required : true } )
activeDid ! : string ;
@ -240,34 +252,27 @@ export default class EntityGrid extends Vue {
* 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 , sorting , and
* display logic beyond the default simple slice behavior .
* 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" )
* @ param maxItems - The maximum number of items to display ( from maxItems prop )
* @ returns Filtered / sorted array of entities to display
*
* @ example
* / / C u s t o m f i l t e r i n g : o n l y s h o w c o n t a c t s w i t h p r o f i l e i m a g e s
* : display - entities - function = " ( entities , type , max ) =>
* entities . filter ( e => e . profileImageUrl ) . slice ( 0 , max ) "
* : display - entities - function = " ( entities , type ) =>
* entities . filter ( e => e . profileImageUrl ) "
*
* @ example
* / / C u s t o m s o r t i n g : s o r t p r o j e c t s b y n a m e
* : display - entities - function = " ( entities , type , max ) =>
* entities . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) . slice ( 0 , max ) "
*
* @ example
* / / A d v a n c e d l o g i c : d i f f e r e n t l i m i t s f o r d i f f e r e n t e n t i t y t y p e s
* : display - entities - function = " ( entities , type , max ) =>
* type === 'projects' ? entities . slice ( 0 , 5 ) : entities . slice ( 0 , max ) "
* : display - entities - function = " ( entities , type ) =>
* entities . sort ( ( a , b ) => a . name . localeCompare ( b . name ) ) "
* /
@ Prop ( { default : null } )
displayEntitiesFunction ? : (
entities : Contact [ ] | PlanData [ ] ,
entityType : "people" | "projects" ,
maxItems : number ,
) => Contact [ ] | PlanData [ ] ;
/ * *
@ -278,27 +283,27 @@ export default class EntityGrid extends Vue {
}
/ * *
* Computed entities to display - uses function prop if provided , otherwise defaults
* When searching , returns filtered results instead of original logic
* 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 [ ] {
/ / I f s e a r c h i n g , r e t u r n f i l t e r e d r e s u l t s
/ / I f s e a r c h i n g , r e t u r n f i l t e r e d r e s u l t s w i t h i n f i n i t e s c r o l l
if ( this . searchTerm . trim ( ) ) {
return this . filteredEntities ;
return this . filteredEntities . slice ( 0 , this . displayedCount ) ;
}
/ / O r i g i n a l l o g i c w h e n n o t s e a r c h i n g
/ / I f c u s t o m f u n c t i o n p r o v i d e d , u s e i t ( d i s a b l e s i n f i n i t e s c r o l l )
if ( this . displayEntitiesFunction ) {
return this . displayEntitiesFunction (
this . entities ,
this . entityType ,
this . maxItems ,
) ;
return this . displayEntitiesFunction ( this . entities , this . entityType ) ;
}
/ / D e f a u l t : p r o j e c t s u s e i n f i n i t e s c r o l l
if ( this . entityType === "projects" ) {
return ( this . entities as PlanData [ ] ) . slice ( 0 , this . displayedCount ) ;
}
/ / D e f a u l t i m p l e m e n t a t i o n f o r b a c k w a r d c o m p a t i b i l i t y
const maxDisplay = this . entityType === "projects" ? 10 : this . maxItems ;
return this . entities . slice ( 0 , maxDisplay ) ;
/ / P e o p l e : h a n d l e d b y r e c e n t C o n t a c t s + a l p h a b e t i c a l C o n t a c t s ( b o t h u s e d i s p l a y e d C o u n t )
return [ ] ;
}
/ * *
@ -314,6 +319,7 @@ export default class EntityGrid extends Vue {
/ * *
* 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 ( ) ) {
@ -321,13 +327,16 @@ export default class EntityGrid extends Vue {
}
/ / S k i p t h e f i r s t 3 ( r e c e n t c o n t a c t s ) a n d s o r t t h e r e s t a l p h a b e t i c a l l y
/ / C r e a t e a c o p y t o a v o i d m u t a t i n g t h e o r i g i n a l a r r a y
const remaining = ( this . entities as Contact [ ] ) . slice ( 3 ) ;
return [ ... remaining ] . sort ( ( a : Contact , b : Contact ) => {
const remaining = ( this . entities as Contact [ ] ) . slice ( RECENT_CONTACTS_COUNT ) ;
const sorted = [ ... remaining ] . sort ( ( a : Contact , b : Contact ) => {
/ / S o r t a l p h a b e t i c a l l y b y n a m e , f a l l i n g b a c k t o D I D i f n a m e i s m i s s i n g
const nameA = ( a . name || a . did ) . toLowerCase ( ) ;
const nameB = ( b . name || b . did ) . toLowerCase ( ) ;
return nameA . localeCompare ( nameB ) ;
} ) ;
/ / A p p l y i n f i n i t e s c r o l l : s h o w b a s e d o n d i s p l a y e d C o u n t ( m i n u s t h e 3 r e c e n t )
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 > {
if ( ! this . searchTerm . trim ( ) ) {
this . filteredEntities = [ ] ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . infiniteScrollReset ? . ( ) ;
return ;
}
@ -474,6 +485,10 @@ export default class EntityGrid extends Vue {
return a . name . toLowerCase ( ) . localeCompare ( b . name . toLowerCase ( ) ) ;
} ) ;
}
/ / R e s e t d i s p l a y e d c o u n t w h e n s e a r c h c o m p l e t e s
this . displayedCount = INITIAL_BATCH_SIZE ;
this . infiniteScrollReset ? . ( ) ;
} finally {
this . isSearching = false ;
}
@ -486,6 +501,8 @@ export default class EntityGrid extends Vue {
this . searchTerm = "" ;
this . filteredEntities = [ ] ;
this . isSearching = false ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . infiniteScrollReset ? . ( ) ;
/ / C l e a r a n y p e n d i n g t i m e o u t
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 ) {
/ / C u s t o m f u n c t i o n d i s a b l e s i n f i n i t e s c r o l l
return false ;
}
if ( this . searchTerm . trim ( ) ) {
/ / S e a r c h m o d e : c h e c k f i l t e r e d e n t i t i e s
return this . displayedCount < this . filteredEntities . length ;
}
if ( this . entityType === "projects" ) {
/ / P r o j e c t s : c h e c k i f m o r e a v a i l a b l e
return this . displayedCount < this . entities . length ;
}
/ / P e o p l e : c h e c k i f m o r e a l p h a b e t i c a l c o n t a c t s a v a i l a b l e
/ / T o t a l a v a i l a b l e = 3 r e c e n t + a l l a l p h a b e t i c a l
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 ,
( ) => {
/ / L o a d m o r e : i n c r e m e n t d i s p l a y e d C o u n t
this . displayedCount += INCREMENT_SIZE ;
} ,
{
distance : 50 , / / p i x e l s f r o m b o t t o m
canLoadMore : ( ) => this . canLoadMore ( ) ,
} ,
) ;
this . infiniteScrollReset = reset ;
}
} ) ;
}
/ / E m i t m e t h o d s u s i n g @ E m i t d e c o r a t o r
@ Emit ( "entity-selected" )
@ -507,6 +574,24 @@ export default class EntityGrid extends Vue {
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
* /