forked from jsnbuchanan/crowd-funder-for-time-pwa
feat: Add quick search to EntityGrid with date-based contact sorting
- Add search-as-you-type functionality with 500ms debounce - Implement search across contact names and DIDs, project names and handleIds - Add loading spinner and dynamic clear button - Add $contactsByDateAdded() method to PlatformServiceMixin for newest-first sorting - Update GiftedDialog to use date-based contact ordering - Maintain backward compatibility with existing $contacts() alphabetical sorting - Add proper cleanup for search timeouts on component unmount The search feature provides real-time filtering with visual feedback, while the new sorting ensures recently added contacts appear first.
This commit is contained in:
@@ -1,7 +1,40 @@
|
||||
/** * 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 */
|
||||
/**
|
||||
* 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'">
|
||||
@@ -101,6 +134,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[];
|
||||
@@ -184,8 +223,15 @@ 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
|
||||
*/
|
||||
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,
|
||||
@@ -283,6 +329,74 @@ 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);
|
||||
});
|
||||
} 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")
|
||||
@@ -295,6 +409,15 @@ export default class EntityGrid extends Vue {
|
||||
} {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup timeouts when component is destroyed
|
||||
*/
|
||||
beforeUnmount(): void {
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ export default class GiftedDialog extends Vue {
|
||||
apiServer: this.apiServer,
|
||||
});
|
||||
|
||||
this.allContacts = await this.$contacts();
|
||||
this.allContacts = await this.$contactsByDateAdded();
|
||||
|
||||
this.allMyDids = await retrieveAccountDids();
|
||||
|
||||
|
||||
@@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
|
||||
return this.$normalizeContacts(rawContacts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all contacts sorted by when they were added (by ID)
|
||||
* Always fetches fresh data from database for consistency
|
||||
* Handles JSON string/object duality for contactMethods field
|
||||
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
|
||||
*/
|
||||
async $contactsByDateAdded(): Promise<Contact[]> {
|
||||
const rawContacts = (await this.$query(
|
||||
"SELECT * FROM contacts ORDER BY id DESC",
|
||||
)) as ContactMaybeWithJsonStrings[];
|
||||
|
||||
return this.$normalizeContacts(rawContacts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ultra-concise shortcut for getting number of contacts
|
||||
* @returns Promise<number> Total number of contacts
|
||||
@@ -2057,6 +2071,7 @@ declare module "@vue/runtime-core" {
|
||||
|
||||
// Specialized shortcuts - contacts cached, settings fresh
|
||||
$contacts(): Promise<Contact[]>;
|
||||
$contactsByDateAdded(): Promise<Contact[]>;
|
||||
$contactCount(): Promise<number>;
|
||||
$settings(defaults?: Settings): Promise<Settings>;
|
||||
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>;
|
||||
|
||||
Reference in New Issue
Block a user