feat(EntityGrid): implement infinite scroll for entity lists
Add infinite scroll functionality to EntityGrid component using VueUse's useInfiniteScroll composable to handle large volumes of entities efficiently. Changes: - Integrate @vueuse/core useInfiniteScroll composable - Add infinite scroll state management (displayedCount, reset function) - Configure initial batch size (20 items) and increment size (20 items) - Update displayedEntities, alphabeticalContacts to support progressive loading - Add canLoadMore() logic for people, projects, and search modes - Reset scroll state when search term or entities prop changes - Remove maxItems prop (replaced by infinite scroll) - Simplify displayEntitiesFunction signature (removed maxItems parameter) - Update EntitySelectionStep and test files to remove max-items prop Technical details: - Uses template ref (scrollContainer) to access scrollable container - Recent contacts (3) count toward initial batch for people grid - Special entities (You, Unnamed) always displayed, don't count toward limits - Infinite scroll works for both entity types and search results - Constants are configurable at top of component (INITIAL_BATCH_SIZE, INCREMENT_SIZE) This improves performance and UX when displaying large lists of contacts or projects by loading content progressively as users scroll.
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
// Infinite scroll state
|
||||
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
|
||||
* // Custom filtering: only show contacts with profile images
|
||||
* :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
|
||||
* // Custom sorting: sort projects by name
|
||||
* :display-entities-function="(entities, type, max) =>
|
||||
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)"
|
||||
*
|
||||
* @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)"
|
||||
* :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[] {
|
||||
// If searching, return filtered results
|
||||
// If searching, return filtered results with infinite scroll
|
||||
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) {
|
||||
return this.displayEntitiesFunction(
|
||||
this.entities,
|
||||
this.entityType,
|
||||
this.maxItems,
|
||||
);
|
||||
return this.displayEntitiesFunction(this.entities, this.entityType);
|
||||
}
|
||||
|
||||
// Default implementation for backward compatibility
|
||||
const maxDisplay = this.entityType === "projects" ? 10 : this.maxItems;
|
||||
return this.entities.slice(0, maxDisplay);
|
||||
// Default: projects use infinite scroll
|
||||
if (this.entityType === "projects") {
|
||||
return (this.entities as PlanData[]).slice(0, this.displayedCount);
|
||||
}
|
||||
|
||||
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||
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 {
|
||||
}
|
||||
// Skip the first 3 (recent contacts) and sort the rest alphabetically
|
||||
// Create a copy to avoid mutating the original array
|
||||
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) => {
|
||||
// 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 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> {
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
// Reset displayed count when search completes
|
||||
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?.();
|
||||
|
||||
// Clear any pending timeout
|
||||
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("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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user