|
|
|
@ -2,12 +2,52 @@ |
|
|
|
GiftedDialog.vue to provide a reusable grid layout * for displaying people, |
|
|
|
projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
|
<template> |
|
|
|
<ul :class="gridClasses"> |
|
|
|
<!-- 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 && searchTerm" |
|
|
|
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" |
|
|
|
v-if="showYouEntity && !searchTerm.trim()" |
|
|
|
entity-type="you" |
|
|
|
label="You" |
|
|
|
icon="hand" |
|
|
|
@ -21,6 +61,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
|
|
|
|
|
<!-- "Unnamed" entity --> |
|
|
|
<SpecialEntityCard |
|
|
|
v-if="showUnnamedEntity && !searchTerm.trim()" |
|
|
|
entity-type="unnamed" |
|
|
|
:label="unnamedEntityName" |
|
|
|
icon="circle-question" |
|
|
|
@ -38,6 +79,49 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
|
|
|
|
|
<!-- Entity cards (people or projects) --> |
|
|
|
<template v-if="entityType === 'people'"> |
|
|
|
<!-- When showing contacts without search: split into recent and alphabetical --> |
|
|
|
<template v-if="!searchTerm.trim()"> |
|
|
|
<!-- Recently Added Section --> |
|
|
|
<template v-if="recentContacts.length > 0"> |
|
|
|
<li |
|
|
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300" |
|
|
|
> |
|
|
|
Recently Added |
|
|
|
</li> |
|
|
|
<PersonCard |
|
|
|
v-for="person in recentContacts" |
|
|
|
:key="person.did" |
|
|
|
:person="person" |
|
|
|
:conflicted="isPersonConflicted(person.did)" |
|
|
|
:show-time-icon="true" |
|
|
|
:notify="notify" |
|
|
|
:conflict-context="conflictContext" |
|
|
|
@person-selected="handlePersonSelected" |
|
|
|
/> |
|
|
|
</template> |
|
|
|
|
|
|
|
<!-- Alphabetical Section --> |
|
|
|
<template v-if="alphabeticalContacts.length > 0"> |
|
|
|
<li |
|
|
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300" |
|
|
|
> |
|
|
|
Everyone Else |
|
|
|
</li> |
|
|
|
<PersonCard |
|
|
|
v-for="person in alphabeticalContacts" |
|
|
|
:key="person.did" |
|
|
|
:person="person" |
|
|
|
:conflicted="isPersonConflicted(person.did)" |
|
|
|
:show-time-icon="true" |
|
|
|
:notify="notify" |
|
|
|
:conflict-context="conflictContext" |
|
|
|
@person-selected="handlePersonSelected" |
|
|
|
/> |
|
|
|
</template> |
|
|
|
</template> |
|
|
|
|
|
|
|
<!-- When searching: show filtered results normally --> |
|
|
|
<template v-else> |
|
|
|
<PersonCard |
|
|
|
v-for="person in displayedEntities as Contact[]" |
|
|
|
:key="person.did" |
|
|
|
@ -49,6 +133,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
|
@person-selected="handlePersonSelected" |
|
|
|
/> |
|
|
|
</template> |
|
|
|
</template> |
|
|
|
|
|
|
|
<template v-else-if="entityType === 'projects'"> |
|
|
|
<ProjectCard |
|
|
|
@ -63,14 +148,6 @@ projects, and special entities with selection. * * @author Matthew Raymer */ |
|
|
|
@project-selected="handleProjectSelected" |
|
|
|
/> |
|
|
|
</template> |
|
|
|
|
|
|
|
<!-- Show All navigation --> |
|
|
|
<ShowAllCard |
|
|
|
v-if="shouldShowAll" |
|
|
|
:entity-type="entityType" |
|
|
|
:route-name="showAllRoute" |
|
|
|
:query-params="showAllQueryParams" |
|
|
|
/> |
|
|
|
</ul> |
|
|
|
</template> |
|
|
|
|
|
|
|
@ -79,7 +156,6 @@ 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 ShowAllCard from "./ShowAllCard.vue"; |
|
|
|
import { Contact } from "../db/tables/contacts"; |
|
|
|
import { PlanData } from "../interfaces/records"; |
|
|
|
import { NotificationIface } from "../constants/app"; |
|
|
|
@ -93,7 +169,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; |
|
|
|
* - Special entity integration (You, Unnamed) |
|
|
|
* - Conflict detection integration |
|
|
|
* - Empty state messaging |
|
|
|
* - Show All navigation |
|
|
|
* - Event delegation for entity selection |
|
|
|
* - Warning notifications for conflicted entities |
|
|
|
* - Template streamlined with computed CSS properties |
|
|
|
@ -104,7 +179,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; |
|
|
|
PersonCard, |
|
|
|
ProjectCard, |
|
|
|
SpecialEntityCard, |
|
|
|
ShowAllCard, |
|
|
|
}, |
|
|
|
}) |
|
|
|
export default class EntityGrid extends Vue { |
|
|
|
@ -112,6 +186,12 @@ export default class EntityGrid extends Vue { |
|
|
|
@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[]; |
|
|
|
@ -140,18 +220,14 @@ export default class EntityGrid extends Vue { |
|
|
|
@Prop({ default: true }) |
|
|
|
showYouEntity!: boolean; |
|
|
|
|
|
|
|
/** Whether to show the "Unnamed" entity for people grids */ |
|
|
|
@Prop({ default: true }) |
|
|
|
showUnnamedEntity!: boolean; |
|
|
|
|
|
|
|
/** Whether the "You" entity is selectable */ |
|
|
|
@Prop({ default: true }) |
|
|
|
youSelectable!: boolean; |
|
|
|
|
|
|
|
/** Route name for "Show All" navigation */ |
|
|
|
@Prop({ default: "" }) |
|
|
|
showAllRoute!: string; |
|
|
|
|
|
|
|
/** Query parameters for "Show All" navigation */ |
|
|
|
@Prop({ default: () => ({}) }) |
|
|
|
showAllQueryParams!: Record<string, string>; |
|
|
|
|
|
|
|
/** Notification function from parent component */ |
|
|
|
@Prop() |
|
|
|
notify?: (notification: NotificationIface, timeout?: number) => void; |
|
|
|
@ -160,10 +236,6 @@ export default class EntityGrid extends Vue { |
|
|
|
@Prop({ default: "other party" }) |
|
|
|
conflictContext!: string; |
|
|
|
|
|
|
|
/** Whether to hide the "Show All" navigation */ |
|
|
|
@Prop({ default: false }) |
|
|
|
hideShowAll!: boolean; |
|
|
|
|
|
|
|
/** |
|
|
|
* Function to determine which entities to display (allows parent control) |
|
|
|
* |
|
|
|
@ -205,23 +277,17 @@ export default class EntityGrid extends Vue { |
|
|
|
return "text-xs text-slate-500 italic col-span-full"; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Computed CSS classes for the grid layout |
|
|
|
*/ |
|
|
|
get gridClasses(): string { |
|
|
|
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4"; |
|
|
|
|
|
|
|
if (this.entityType === "projects") { |
|
|
|
return `${baseClasses} grid-cols-3 md:grid-cols-4`; |
|
|
|
} else { |
|
|
|
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 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, |
|
|
|
@ -231,10 +297,39 @@ export default class EntityGrid extends Vue { |
|
|
|
} |
|
|
|
|
|
|
|
// Default implementation for backward compatibility |
|
|
|
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems; |
|
|
|
const maxDisplay = this.entityType === "projects" ? 10 : this.maxItems; |
|
|
|
return this.entities.slice(0, maxDisplay); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Get the 3 most recently added contacts (when showing contacts and not searching) |
|
|
|
*/ |
|
|
|
get recentContacts(): Contact[] { |
|
|
|
if (this.entityType !== "people" || this.searchTerm.trim()) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
// Entities are already sorted by date added (newest first) |
|
|
|
return (this.entities as Contact[]).slice(0, 3); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching) |
|
|
|
*/ |
|
|
|
get alphabeticalContacts(): Contact[] { |
|
|
|
if (this.entityType !== "people" || this.searchTerm.trim()) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
// 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) => { |
|
|
|
// 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); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Computed empty state message based on entity type |
|
|
|
*/ |
|
|
|
@ -246,15 +341,6 @@ export default class EntityGrid extends Vue { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Whether to show the "Show All" navigation |
|
|
|
*/ |
|
|
|
get shouldShowAll(): boolean { |
|
|
|
return ( |
|
|
|
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== "" |
|
|
|
); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Whether the "You" entity is conflicted |
|
|
|
*/ |
|
|
|
@ -328,6 +414,86 @@ export default class EntityGrid extends Vue { |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 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); |
|
|
|
}) |
|
|
|
.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); |
|
|
|
}); |
|
|
|
} 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); |
|
|
|
}) |
|
|
|
.sort((a: PlanData, b: PlanData) => { |
|
|
|
// Sort alphabetically by name |
|
|
|
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); |
|
|
|
}); |
|
|
|
} |
|
|
|
} 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") |
|
|
|
@ -340,6 +506,15 @@ export default class EntityGrid extends Vue { |
|
|
|
} { |
|
|
|
return data; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Cleanup timeouts when component is destroyed |
|
|
|
*/ |
|
|
|
beforeUnmount(): void { |
|
|
|
if (this.searchTimeout) { |
|
|
|
clearTimeout(this.searchTimeout); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
</script> |
|
|
|
|
|
|
|
|