@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records" ;
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" ;
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
* Constants for infinite scroll configuration
@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
ProjectCard ,
ProjectCard ,
SpecialEntityCard ,
SpecialEntityCard ,
} ,
} ,
mixins : [ PlatformServiceMixin ] ,
} )
} )
export default class EntityGrid extends Vue {
export default class EntityGrid extends Vue {
/** Type of entities to display */
/** Type of entities to display */
@ -202,6 +207,11 @@ export default class EntityGrid extends Vue {
isSearching = false ;
isSearching = false ;
searchTimeout : NodeJS . Timeout | null = null ;
searchTimeout : NodeJS . Timeout | null = null ;
filteredEntities : Contact [ ] | PlanData [ ] = [ ] ;
filteredEntities : Contact [ ] | PlanData [ ] = [ ] ;
searchBeforeId : string | undefined = undefined ;
isLoadingSearchMore = false ;
/ / A P I s e r v e r f o r p r o j e c t s e a r c h e s
apiServer = "" ;
/ / I n f i n i t e s c r o l l s t a t e
/ / I n f i n i t e s c r o l l s t a t e
displayedCount = INITIAL_BATCH_SIZE ;
displayedCount = INITIAL_BATCH_SIZE ;
@ -212,14 +222,15 @@ export default class EntityGrid extends Vue {
/ * *
/ * *
* Array of entities to display
* Array of entities to display
*
*
* For contacts : 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
* IMPORTANT : When passing Contact [ ] arrays , they must be sorted by date added
* ( newest first ) for the "Recently Added" section to display correctly .
* ( 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
* For projects : Can be partial list ( pagination supported ) .
* by date added and simply takes the first 3. If contacts are sorted
* Server - side search will fetch matching results with pagination ,
* alphabetically or in another order , the wrong contacts will appear in
* regardless of what ' s in this prop .
* "Recently Added" .
* /
* /
@ Prop ( { required : true } )
@ Prop ( { required : true } )
entities ! : Contact [ ] | PlanData [ ] ;
entities ! : Contact [ ] | PlanData [ ] ;
@ -475,24 +486,162 @@ export default class EntityGrid extends Vue {
/ * *
/ * *
* Perform the actual search
* Perform the actual search
* Routes to server - side search for projects or client - side filtering for contacts
* /
* /
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 . displayedCount = INITIAL_BATCH_SIZE ;
this . searchBeforeId = undefined ;
this . infiniteScrollReset ? . ( ) ;
this . infiniteScrollReset ? . ( ) ;
return ;
return ;
}
}
this . isSearching = true ;
this . isSearching = true ;
this . searchBeforeId = undefined ; / / R e s e t p a g i n a t i o n f o r n e w s e a r c h
try {
try {
/ / S i m u l a t e a s y n c s e a r c h ( i n c a s e w e n e e d t o a d d A P I c a l l s l a t e r )
if ( this . entityType === "projects" ) {
/ / S e r v e r - s i d e s e a r c h f o r p r o j e c t s ( i n i t i a l l o a d , n o b e f o r e I d )
await this . performProjectSearch ( ) ;
} else {
/ / C l i e n t - s i d e f i l t e r i n g f o r c o n t a c t s ( c o m p l e t e l i s t )
await this . performContactSearch ( ) ;
}
/ / 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 ;
}
}
/ * *
* Perform server - side project search with optional pagination
* Uses claimContents parameter for search and beforeId for pagination .
* Results are appended when paginating , replaced on initial search .
*
* @ param beforeId - Optional rowId for pagination ( loads projects before this ID )
* /
async performProjectSearch ( beforeId ? : string ) : Promise < void > {
if ( ! this . apiServer ) {
this . filteredEntities = [ ] ;
if ( this . notify ) {
this . notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : "API server not configured" ,
} ,
TIMEOUTS . SHORT ,
) ;
}
return ;
}
const searchLower = this . searchTerm . toLowerCase ( ) . trim ( ) ;
let url = ` ${ this . apiServer } /api/v2/report/plans?claimContents= ${ encodeURIComponent ( searchLower ) } ` ;
if ( beforeId ) {
url += ` &beforeId= ${ encodeURIComponent ( beforeId ) } ` ;
}
try {
const response = await fetch ( url , {
method : "GET" ,
headers : await getHeaders ( this . activeDid ) ,
} ) ;
if ( response . status !== 200 ) {
throw new Error ( "Failed to search projects" ) ;
}
const results = await response . json ( ) ;
if ( results . data ) {
const newProjects = results . data . map (
( plan : PlanData & { rowId ? : string } ) => ( {
... plan ,
rowId : plan . rowId ,
} ) ,
) ;
logger . debug ( "[EntityGrid] Project search results" , {
beforeId ,
newProjectsCount : newProjects . length ,
hasRowId :
newProjects . length > 0
? ! ! newProjects [ newProjects . length - 1 ] ? . rowId
: false ,
lastRowId :
newProjects . length > 0
? newProjects [ newProjects . length - 1 ] ? . rowId
: undefined ,
} ) ;
if ( beforeId ) {
/ / P a g i n a t i o n : a p p e n d n e w p r o j e c t s t o e x i s t i n g s e a r c h r e s u l t s
this . filteredEntities . push ( ... newProjects ) ;
} else {
/ / I n i t i a l s e a r c h : r e p l a c e a r r a y
this . filteredEntities = newProjects ;
}
/ / U p d a t e s e a r c h B e f o r e I d f o r n e x t p a g i n a t i o n
/ / U s e t h e l a s t p r o j e c t ' s r o w I d , o r u n d e f i n e d i f n o m o r e r e s u l t s
if ( newProjects . length > 0 ) {
const lastProject = newProjects [ newProjects . length - 1 ] ;
/ / O n l y s e t s e a r c h B e f o r e I d i f r o w I d e x i s t s ( i n d i c a t e s m o r e r e s u l t s a v a i l a b l e )
this . searchBeforeId = lastProject . rowId || undefined ;
logger . debug ( "[EntityGrid] Updated searchBeforeId" , {
searchBeforeId : this . searchBeforeId ,
filteredEntitiesCount : this . filteredEntities . length ,
} ) ;
} else {
this . searchBeforeId = undefined ; / / N o m o r e r e s u l t s
logger . debug ( "[EntityGrid] No more search results" , {
filteredEntitiesCount : this . filteredEntities . length ,
} ) ;
}
} else {
if ( ! beforeId ) {
/ / O n l y c l e a r o n i n i t i a l s e a r c h , n o t p a g i n a t i o n
this . filteredEntities = [ ] ;
}
this . searchBeforeId = undefined ;
}
} catch ( error ) {
logger . error ( "Error searching projects:" , error ) ;
if ( ! beforeId ) {
/ / O n l y c l e a r o n i n i t i a l s e a r c h e r r o r , n o t p a g i n a t i o n e r r o r
this . filteredEntities = [ ] ;
}
this . searchBeforeId = undefined ;
if ( this . notify ) {
this . notify (
{
group : "alert" ,
type : "danger" ,
title : "Error" ,
text : "Failed to search projects. Please try again." ,
} ,
TIMEOUTS . STANDARD ,
) ;
}
}
}
/ * *
* Client - side contact search
* Assumes entities prop contains complete contact list from local database
* /
async performContactSearch ( ) : Promise < void > {
/ / S i m u l a t e a s y n c ( f o r c o n s i s t e n c y w i t h p r o j e c t s e a r c h )
await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
const searchLower = this . searchTerm . toLowerCase ( ) . trim ( ) ;
const searchLower = this . searchTerm . toLowerCase ( ) . trim ( ) ;
if ( this . entityType === "people" ) {
this . filteredEntities = ( this . entities as Contact [ ] )
this . filteredEntities = ( this . entities as Contact [ ] )
. filter ( ( contact : Contact ) => {
. filter ( ( contact : Contact ) => {
const name = contact . name ? . toLowerCase ( ) || "" ;
const name = contact . name ? . toLowerCase ( ) || "" ;
@ -505,25 +654,9 @@ export default class EntityGrid extends Vue {
const nameB = ( b . name || b . did ) . toLowerCase ( ) ;
const nameB = ( b . name || b . did ) . toLowerCase ( ) ;
return nameA . localeCompare ( nameB ) ;
return nameA . localeCompare ( nameB ) ;
} ) ;
} ) ;
} 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 ) => {
/ / S o r t a l p h a b e t i c a l l y b y n a m e
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
/ / C o n t a c t s d o n ' t n e e d p a g i n a t i o n ( c o m p l e t e l i s t )
this . displayedCount = INITIAL_BATCH_SIZE ;
this . searchBeforeId = undefined ;
this . infiniteScrollReset ? . ( ) ;
} finally {
this . isSearching = false ;
}
}
}
/ * *
/ * *
@ -534,6 +667,7 @@ export default class EntityGrid extends Vue {
this . filteredEntities = [ ] ;
this . filteredEntities = [ ] ;
this . isSearching = false ;
this . isSearching = false ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . searchBeforeId = undefined ;
this . infiniteScrollReset ? . ( ) ;
this . infiniteScrollReset ? . ( ) ;
/ / C l e a r a n y p e n d i n g t i m e o u t
/ / C l e a r a n y p e n d i n g t i m e o u t
@ -553,13 +687,27 @@ export default class EntityGrid extends Vue {
}
}
if ( this . searchTerm . trim ( ) ) {
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
/ / S e a r c h m o d e : c h e c k i f m o r e r e s u l t s a v a i l a b l e
if ( this . entityType === "projects" ) {
/ / P r o j e c t s : c a n l o a d m o r e i f :
/ / 1 . W e h a v e m o r e a l r e a d y - l o a d e d r e s u l t s t o s h o w , O R
/ / 2 . W e ' v e s h o w n a l l l o a d e d r e s u l t s A N D t h e r e ' s a s e a r c h B e f o r e I d t o l o a d m o r e
const hasMoreLoaded =
this . displayedCount < this . filteredEntities . length ;
const canLoadMoreFromServer =
this . displayedCount >= this . filteredEntities . length &&
! ! this . searchBeforeId &&
! this . isLoadingSearchMore ;
return hasMoreLoaded || canLoadMoreFromServer ;
} else {
/ / C o n t a c t s : c l i e n t - s i d e f i l t e r i n g r e t u r n s a l l r e s u l t s a t o n c e
return this . displayedCount < this . filteredEntities . length ;
return this . displayedCount < this . filteredEntities . length ;
}
}
}
/ / N o n - s e a r c h m o d e : e x i s t i n g l o g i c
if ( this . entityType === "projects" ) {
if ( this . entityType === "projects" ) {
/ / P r o j e c t s : i f w e ' v e s h o w n a l l l o a d e d e n t i t i e s , c a l l b a c k h a n d l e s s e r v e r - s i d e a v a i l a b i l i t y
/ / P r o j e c t s : i f w e ' v e s h o w n a l l l o a d e d e n t i t i e s a n d c a l l b a c k e x i s t s , c a l l b a c k h a n d l e s s e r v e r - s i d e a v a i l a b i l i t y
/ / I f c a l l b a c k e x i s t s a n d w e ' v e r e a c h e d t h e e n d , a s s u m e m o r e m i g h t b e a v a i l a b l e
if (
if (
this . displayedCount >= this . entities . length &&
this . displayedCount >= this . entities . length &&
this . loadMoreCallback
this . loadMoreCallback
@ -579,7 +727,13 @@ export default class EntityGrid extends Vue {
/ * *
/ * *
* Initialize infinite scroll on mount
* Initialize infinite scroll on mount
* /
* /
mounted ( ) : void {
async mounted ( ) : Promise < void > {
/ / L o a d a p i S e r v e r f o r p r o j e c t s e a r c h e s
if ( this . entityType === "projects" ) {
const settings = await this . $accountSettings ( ) ;
this . apiServer = settings . apiServer || "" ;
}
this . $nextTick ( ( ) => {
this . $nextTick ( ( ) => {
const container = this . $refs . scrollContainer as HTMLElement ;
const container = this . $refs . scrollContainer as HTMLElement ;
@ -587,6 +741,36 @@ export default class EntityGrid extends Vue {
const { reset } = useInfiniteScroll (
const { reset } = useInfiniteScroll (
container ,
container ,
async ( ) => {
async ( ) => {
/ / S e a r c h m o d e : h a n d l e s e a r c h p a g i n a t i o n
if ( this . searchTerm . trim ( ) ) {
if ( this . entityType === "projects" ) {
/ / P r o j e c t s : l o a d m o r e s e a r c h r e s u l t s i f a v a i l a b l e
if (
this . displayedCount >= this . filteredEntities . length &&
this . searchBeforeId &&
! this . isLoadingSearchMore
) {
this . isLoadingSearchMore = true ;
try {
await this . performProjectSearch ( this . searchBeforeId ) ;
/ / A f t e r l o a d i n g m o r e , r e s e t s c r o l l s t a t e t o a l l o w f u r t h e r l o a d i n g
this . infiniteScrollReset ? . ( ) ;
} catch ( error ) {
logger . error ( "Error loading more search results:" , error ) ;
/ / E r r o r a l r e a d y h a n d l e d i n p e r f o r m P r o j e c t S e a r c h
} finally {
this . isLoadingSearchMore = false ;
}
} else {
/ / S h o w m o r e f r o m a l r e a d y - l o a d e d s e a r c h r e s u l t s
this . displayedCount += INCREMENT_SIZE ;
}
} else {
/ / C o n t a c t s : s h o w m o r e f r o m a l r e a d y - f i l t e r e d r e s u l t s
this . displayedCount += INCREMENT_SIZE ;
}
} else {
/ / N o n - s e a r c h m o d e : e x i s t i n g l o g i c
/ / F o r p r o j e c t s : i f w e ' v e s h o w n a l l e n t i t i e s a n d c a l l b a c k e x i s t s , c a l l i t
/ / F o r p r o j e c t s : i f w e ' v e s h o w n a l l e n t i t i e s a n d c a l l b a c k e x i s t s , c a l l i t
if (
if (
this . entityType === "projects" &&
this . entityType === "projects" &&
@ -610,6 +794,7 @@ export default class EntityGrid extends Vue {
/ / N o r m a l c a s e : i n c r e m e n t d i s p l a y e d C o u n t t o s h o w m o r e f r o m m e m o r y
/ / N o r m a l c a s e : i n c r e m e n t d i s p l a y e d C o u n t t o s h o w m o r e f r o m m e m o r y
this . displayedCount += INCREMENT_SIZE ;
this . displayedCount += INCREMENT_SIZE ;
}
}
}
} ,
} ,
{
{
distance : 50 , / / p i x e l s f r o m b o t t o m
distance : 50 , / / p i x e l s f r o m b o t t o m
@ -635,19 +820,27 @@ 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" )
@ Watch ( "searchTerm" )
onSearchTermChange ( ) : void {
onSearchTermChange ( ) : void {
/ / R e s e t d i s p l a y e d c o u n t a n d p a g i n a t i o n w h e n s e a r c h t e r m c h a n g e s
this . displayedCount = INITIAL_BATCH_SIZE ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . searchBeforeId = undefined ;
this . infiniteScrollReset ? . ( ) ;
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" )
@ Watch ( "entities" )
onEntitiesChange ( ) : void {
onEntitiesChange ( ) : void {
/ / C l e a r s e a r c h w h e n e n t i t i e s c h a n g e ( f r e s h d i a l o g o p e n )
if ( this . searchTerm ) {
this . searchTerm = "" ;
this . filteredEntities = [ ] ;
this . searchBeforeId = undefined ;
}
this . displayedCount = INITIAL_BATCH_SIZE ;
this . displayedCount = INITIAL_BATCH_SIZE ;
this . infiniteScrollReset ? . ( ) ;
this . infiniteScrollReset ? . ( ) ;
}
}