You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
426 lines
12 KiB
426 lines
12 KiB
/**
|
|
* 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>
|
|
|
|
<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>
|
|
|