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 20 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. 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
*/ */

1
src/components/EntitySelectionStep.vue

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

16
src/test/EntityGridFunctionPropTest.vue

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

Loading…
Cancel
Save