feat(EntityGrid): implement infinite scroll for entity lists #215

Merged
jose merged 1 commits from entity-selection-list-component-infinite-scroll into entity-selection-list-component 14 hours ago
  1. 153
      src/components/EntityGrid.vue
  2. 1
      src/components/EntitySelectionStep.vue
  3. 16
      src/test/EntityGridFunctionPropTest.vue

153
src/components/EntityGrid.vue

@ -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: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
}
// Default implementation for backward compatibility
const maxDisplay = this.entityType === "projects" ? 10 : this.maxItems;
return this.entities.slice(0, maxDisplay);
// 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
*/

1
src/components/EntitySelectionStep.vue

@ -15,7 +15,6 @@ properties * * @author Matthew Raymer */
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"

16
src/test/EntityGridFunctionPropTest.vue

@ -19,7 +19,6 @@
<EntityGrid
entity-type="people"
:entities="people"
:max-items="5"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@ -39,7 +38,6 @@
<EntityGrid
entity-type="projects"
:entities="projects"
:max-items="3"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="people"
@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = (
entities: Contact[],
_entityType: string,
maxItems: number,
): Contact[] => {
return entities
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
return entities.filter((person) => person.profileImageUrl);
};
/**
@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = (
entities: PlanData[],
_entityType: string,
_maxItems: number,
): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
};
@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/
get displayedPeopleCount(): number {
if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people", 5).length;
return this.customPeopleFunction(this.people, "people").length;
}
return Math.min(5, this.people.length);
return Math.min(10, this.people.length); // Initial batch size for infinite scroll
}
get displayedProjectsCount(): number {
if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects", 3).length;
return this.customProjectsFunction(this.projects, "projects").length;
}
return Math.min(7, this.projects.length);
return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
}
}
</script>

Loading…
Cancel
Save