forked from trent_larson/crowd-funder-for-time-pwa
Add contextual feedback message when a search term is entered but no matching entities are found. The message dynamically adjusts its wording based on whether searching for people or projects.
438 lines
12 KiB
Vue
438 lines
12 KiB
Vue
/** * EntityGrid.vue - Unified entity grid layout component * * Extracted from
|
|
GiftedDialog.vue to provide a reusable grid layout * for displaying people,
|
|
projects, and special entities with selection. * * @author Matthew Raymer */
|
|
<template>
|
|
<!-- Quick Search -->
|
|
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
|
|
<input
|
|
v-model="searchTerm"
|
|
type="text"
|
|
placeholder="Search…"
|
|
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
|
|
@input="handleSearchInput"
|
|
@keydown.enter="performSearch"
|
|
/>
|
|
<div
|
|
v-show="isSearching"
|
|
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
|
|
>
|
|
<font-awesome
|
|
icon="spinner"
|
|
class="fa-spin-pulse leading-[1.1]"
|
|
></font-awesome>
|
|
</div>
|
|
<button
|
|
:disabled="!searchTerm"
|
|
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
|
|
@click="clearSearch"
|
|
>
|
|
<font-awesome
|
|
:icon="searchTerm ? 'times' : 'magnifying-glass'"
|
|
class="fa-fw"
|
|
></font-awesome>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
|
|
class="mb-4 text-sm italic text-slate-500 text-center"
|
|
>
|
|
“{{ searchTerm }}” doesn't match any
|
|
{{ entityType === "people" ? "people" : "projects" }}. Try a different
|
|
search.
|
|
</div>
|
|
|
|
<ul 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 -->
|
|
<SpecialEntityCard
|
|
v-if="showYouEntity"
|
|
entity-type="you"
|
|
label="You"
|
|
icon="hand"
|
|
:selectable="youSelectable"
|
|
:conflicted="youConflicted"
|
|
:entity-data="youEntityData"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@entity-selected="handleEntitySelected"
|
|
/>
|
|
|
|
<!-- "Unnamed" entity -->
|
|
<SpecialEntityCard
|
|
entity-type="unnamed"
|
|
:label="unnamedEntityName"
|
|
icon="circle-question"
|
|
:entity-data="unnamedEntityData"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@entity-selected="handleEntitySelected"
|
|
/>
|
|
</template>
|
|
|
|
<!-- Empty state message -->
|
|
<li v-if="entities.length === 0" :class="emptyStateClasses">
|
|
{{ emptyStateMessage }}
|
|
</li>
|
|
|
|
<!-- Entity cards (people or projects) -->
|
|
<template v-if="entityType === 'people'">
|
|
<PersonCard
|
|
v-for="person in displayedEntities as Contact[]"
|
|
:key="person.did"
|
|
:person="person"
|
|
:conflicted="isPersonConflicted(person.did)"
|
|
:show-time-icon="true"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@person-selected="handlePersonSelected"
|
|
/>
|
|
</template>
|
|
|
|
<template v-else-if="entityType === 'projects'">
|
|
<ProjectCard
|
|
v-for="project in displayedEntities as PlanData[]"
|
|
:key="project.handleId"
|
|
:project="project"
|
|
:active-did="activeDid"
|
|
:all-my-dids="allMyDids"
|
|
:all-contacts="allContacts"
|
|
:notify="notify"
|
|
:conflict-context="conflictContext"
|
|
@project-selected="handleProjectSelected"
|
|
/>
|
|
</template>
|
|
</ul>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
|
import PersonCard from "./PersonCard.vue";
|
|
import ProjectCard from "./ProjectCard.vue";
|
|
import SpecialEntityCard from "./SpecialEntityCard.vue";
|
|
import { Contact } from "../db/tables/contacts";
|
|
import { PlanData } from "../interfaces/records";
|
|
import { NotificationIface } from "../constants/app";
|
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
|
|
|
/**
|
|
* EntityGrid - Unified grid layout for displaying people or projects
|
|
*
|
|
* Features:
|
|
* - Responsive grid layout for people/projects
|
|
* - Special entity integration (You, Unnamed)
|
|
* - Conflict detection integration
|
|
* - Empty state messaging
|
|
* - Event delegation for entity selection
|
|
* - Warning notifications for conflicted entities
|
|
* - Template streamlined with computed CSS properties
|
|
* - Configurable entity display logic via function props
|
|
*/
|
|
@Component({
|
|
components: {
|
|
PersonCard,
|
|
ProjectCard,
|
|
SpecialEntityCard,
|
|
},
|
|
})
|
|
export default class EntityGrid extends Vue {
|
|
/** Type of entities to display */
|
|
@Prop({ required: true })
|
|
entityType!: "people" | "projects";
|
|
|
|
// Search state
|
|
searchTerm = "";
|
|
isSearching = false;
|
|
searchTimeout: NodeJS.Timeout | null = null;
|
|
filteredEntities: Contact[] | PlanData[] = [];
|
|
|
|
/** 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;
|
|
|
|
/** All user's DIDs */
|
|
@Prop({ required: true })
|
|
allMyDids!: string[];
|
|
|
|
/** All contacts */
|
|
@Prop({ required: true })
|
|
allContacts!: Contact[];
|
|
|
|
/** Function to check if a person DID would create a conflict */
|
|
@Prop({ required: true })
|
|
conflictChecker!: (did: string) => boolean;
|
|
|
|
/** Whether to show the "You" entity for people grids */
|
|
@Prop({ default: true })
|
|
showYouEntity!: boolean;
|
|
|
|
/** Whether the "You" entity is selectable */
|
|
@Prop({ default: true })
|
|
youSelectable!: boolean;
|
|
|
|
/** Notification function from parent component */
|
|
@Prop()
|
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
|
|
|
/** Context for conflict messages (e.g., "giver", "recipient") */
|
|
@Prop({ default: "other party" })
|
|
conflictContext!: string;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @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)"
|
|
*
|
|
* @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)"
|
|
*/
|
|
@Prop({ default: null })
|
|
displayEntitiesFunction?: (
|
|
entities: Contact[] | PlanData[],
|
|
entityType: "people" | "projects",
|
|
maxItems: number,
|
|
) => Contact[] | PlanData[];
|
|
|
|
/**
|
|
* CSS classes for the empty state message
|
|
*/
|
|
get emptyStateClasses(): string {
|
|
return "text-xs text-slate-500 italic col-span-full";
|
|
}
|
|
|
|
/**
|
|
* Computed entities to display - uses function prop if provided, otherwise defaults
|
|
* When searching, returns filtered results instead of original logic
|
|
*/
|
|
get displayedEntities(): Contact[] | PlanData[] {
|
|
// If searching, return filtered results
|
|
if (this.searchTerm.trim()) {
|
|
return this.filteredEntities;
|
|
}
|
|
|
|
// Original logic when not searching
|
|
if (this.displayEntitiesFunction) {
|
|
return this.displayEntitiesFunction(
|
|
this.entities,
|
|
this.entityType,
|
|
this.maxItems,
|
|
);
|
|
}
|
|
|
|
// Default implementation for backward compatibility
|
|
const maxDisplay = this.entityType === "projects" ? 10 : this.maxItems;
|
|
return this.entities.slice(0, maxDisplay);
|
|
}
|
|
|
|
/**
|
|
* Computed empty state message based on entity type
|
|
*/
|
|
get emptyStateMessage(): string {
|
|
if (this.entityType === "projects") {
|
|
return "(No projects found.)";
|
|
} else {
|
|
return "(Add friends to see more people worthy of recognition.)";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the "You" entity is conflicted
|
|
*/
|
|
get youConflicted(): boolean {
|
|
return this.conflictChecker(this.activeDid);
|
|
}
|
|
|
|
/**
|
|
* Entity data for the "You" special entity
|
|
*/
|
|
get youEntityData(): { did: string; name: string } {
|
|
return {
|
|
did: this.activeDid,
|
|
name: "You",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Entity data for the "Unnamed" special entity
|
|
*/
|
|
get unnamedEntityData(): { did: string; name: string } {
|
|
return {
|
|
did: "",
|
|
name: UNNAMED_ENTITY_NAME,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the unnamed entity name constant
|
|
*/
|
|
get unnamedEntityName(): string {
|
|
return UNNAMED_ENTITY_NAME;
|
|
}
|
|
|
|
/**
|
|
* Check if a person DID is conflicted
|
|
*/
|
|
isPersonConflicted(did: string): boolean {
|
|
return this.conflictChecker(did);
|
|
}
|
|
|
|
/**
|
|
* Handle person selection from PersonCard
|
|
*/
|
|
handlePersonSelected(person: Contact): void {
|
|
this.emitEntitySelected({
|
|
type: "person",
|
|
data: person,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle project selection from ProjectCard
|
|
*/
|
|
handleProjectSelected(project: PlanData): void {
|
|
this.emitEntitySelected({
|
|
type: "project",
|
|
data: project,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle special entity selection from SpecialEntityCard
|
|
* Treat "You" and "Unnamed" as person entities
|
|
*/
|
|
handleEntitySelected(event: { data: { did?: string; name: string } }): void {
|
|
// Convert special entities to person entities since they represent people
|
|
this.emitEntitySelected({
|
|
type: "person",
|
|
data: event.data as Contact,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle search input with debouncing
|
|
*/
|
|
handleSearchInput(): void {
|
|
// Show spinner immediately when user types
|
|
this.isSearching = true;
|
|
|
|
// Clear existing timeout
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
|
|
// Set new timeout for 500ms delay
|
|
this.searchTimeout = setTimeout(() => {
|
|
this.performSearch();
|
|
}, 500);
|
|
}
|
|
|
|
/**
|
|
* Perform the actual search
|
|
*/
|
|
async performSearch(): Promise<void> {
|
|
if (!this.searchTerm.trim()) {
|
|
this.filteredEntities = [];
|
|
return;
|
|
}
|
|
|
|
this.isSearching = true;
|
|
|
|
try {
|
|
// Simulate async search (in case we need to add API calls later)
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
|
|
if (this.entityType === "people") {
|
|
this.filteredEntities = (this.entities as Contact[]).filter(
|
|
(contact: Contact) => {
|
|
const name = contact.name?.toLowerCase() || "";
|
|
const did = contact.did.toLowerCase();
|
|
return name.includes(searchLower) || did.includes(searchLower);
|
|
},
|
|
);
|
|
} 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);
|
|
},
|
|
);
|
|
}
|
|
} finally {
|
|
this.isSearching = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the search
|
|
*/
|
|
clearSearch(): void {
|
|
this.searchTerm = "";
|
|
this.filteredEntities = [];
|
|
this.isSearching = false;
|
|
|
|
// Clear any pending timeout
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = null;
|
|
}
|
|
}
|
|
|
|
// Emit methods using @Emit decorator
|
|
|
|
@Emit("entity-selected")
|
|
emitEntitySelected(data: {
|
|
type: "person" | "project";
|
|
data: Contact | PlanData;
|
|
}): {
|
|
type: "person" | "project";
|
|
data: Contact | PlanData;
|
|
} {
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Cleanup timeouts when component is destroyed
|
|
*/
|
|
beforeUnmount(): void {
|
|
if (this.searchTimeout) {
|
|
clearTimeout(this.searchTimeout);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Grid-specific styles if needed */
|
|
</style>
|