Browse Source
- Create EntityGrid.vue for unified entity grid layout * Responsive grid layout for people and projects * Integrates special entities (You, Unnamed) seamlessly * Conflict detection integration with prop-based checker * Empty state messaging based on entity type * Show All navigation with query parameter support * Event delegation for all entity selection types * Configurable display limits and grid columns - Create SpecialEntityCard.vue for special entity handling * Handles 'You' and 'Unnamed' entity types * FontAwesome icon integration with configurable styling * Conflict state handling with visual feedback * Entity-type-specific color schemes (blue for You, gray for Unnamed) * Emits structured events with entity data - Create ShowAllCard.vue for navigation functionality * Router-link integration with query parameter passing * Consistent visual styling with other entity cards * Hover effects and animations * Maintains context through configurable route params - Update GiftedDialog-Decomposition-Plan.md * Mark Phase 2 as completed * Add comprehensive integration examples * Update component specifications with actual props/emits * Add EntityGrid, SpecialEntityCard, and ShowAllCard usage patterns * Update project status and next steps Phase 2 creates reusable layout components that can replace the complex grid logic in GiftedDialog. The EntityGrid component provides a clean API for entity selection while maintaining all existing functionality. Components: 7 total (4 Phase 1 + 3 Phase 2) Next: Integration phase - Update GiftedDialog to use new componentsmatthew-scratch-2025-06-28
4 changed files with 556 additions and 16 deletions
@ -0,0 +1,259 @@ |
|||||
|
/** |
||||
|
* 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> |
||||
|
<ul :class="gridClasses"> |
||||
|
<!-- 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" |
||||
|
@entity-selected="handleEntitySelected" |
||||
|
/> |
||||
|
|
||||
|
<!-- "Unnamed" entity --> |
||||
|
<SpecialEntityCard |
||||
|
entity-type="unnamed" |
||||
|
label="Unnamed" |
||||
|
icon="circle-question" |
||||
|
:entity-data="unnamedEntityData" |
||||
|
@entity-selected="handleEntitySelected" |
||||
|
/> |
||||
|
</template> |
||||
|
|
||||
|
<!-- Empty state message --> |
||||
|
<li |
||||
|
v-if="entities.length === 0" |
||||
|
class="text-xs text-slate-500 italic col-span-full" |
||||
|
> |
||||
|
{{ emptyStateMessage }} |
||||
|
</li> |
||||
|
|
||||
|
<!-- Entity cards (people or projects) --> |
||||
|
<template v-if="entityType === 'people'"> |
||||
|
<PersonCard |
||||
|
v-for="person in displayedEntities" |
||||
|
:key="person.did" |
||||
|
:person="person" |
||||
|
:conflicted="isPersonConflicted(person.did)" |
||||
|
:show-time-icon="true" |
||||
|
@person-selected="handlePersonSelected" |
||||
|
/> |
||||
|
</template> |
||||
|
|
||||
|
<template v-else-if="entityType === 'projects'"> |
||||
|
<ProjectCard |
||||
|
v-for="project in displayedEntities" |
||||
|
:key="project.handleId" |
||||
|
:project="project" |
||||
|
:active-did="activeDid" |
||||
|
:all-my-dids="allMyDids" |
||||
|
:all-contacts="allContacts" |
||||
|
@project-selected="handleProjectSelected" |
||||
|
/> |
||||
|
</template> |
||||
|
|
||||
|
<!-- Show All navigation --> |
||||
|
<ShowAllCard |
||||
|
v-if="shouldShowAll" |
||||
|
:entity-type="entityType" |
||||
|
:route-name="showAllRoute" |
||||
|
:query-params="showAllQueryParams" |
||||
|
/> |
||||
|
</ul> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Prop, Vue } 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 "@/interfaces/contact"; |
||||
|
import { PlanData } from "@/interfaces/plan-data"; |
||||
|
|
||||
|
/** |
||||
|
* EntityGrid - Unified grid layout for entity selection |
||||
|
* |
||||
|
* Features: |
||||
|
* - Responsive grid layout for people or projects |
||||
|
* - Special entity handling (You, Unnamed) |
||||
|
* - Conflict detection integration |
||||
|
* - Empty state messaging |
||||
|
* - Show All navigation |
||||
|
* - Configurable display limits |
||||
|
* - Event delegation for entity selection |
||||
|
*/ |
||||
|
@Component({ |
||||
|
components: { |
||||
|
PersonCard, |
||||
|
ProjectCard, |
||||
|
SpecialEntityCard, |
||||
|
ShowAllCard, |
||||
|
}, |
||||
|
}) |
||||
|
export default class EntityGrid extends Vue { |
||||
|
/** Type of entities to display */ |
||||
|
@Prop({ required: true }) |
||||
|
entityType!: "people" | "projects"; |
||||
|
|
||||
|
/** 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; |
||||
|
|
||||
|
/** Route name for "Show All" navigation */ |
||||
|
@Prop({ default: "" }) |
||||
|
showAllRoute!: string; |
||||
|
|
||||
|
/** Query parameters for "Show All" navigation */ |
||||
|
@Prop({ default: () => ({}) }) |
||||
|
showAllQueryParams!: Record<string, any>; |
||||
|
|
||||
|
/** |
||||
|
* 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 (limited by maxItems) |
||||
|
*/ |
||||
|
get displayedEntities(): Contact[] | PlanData[] { |
||||
|
const maxDisplay = this.entityType === "projects" ? 7 : 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 to show the "Show All" navigation |
||||
|
*/ |
||||
|
get shouldShowAll(): boolean { |
||||
|
return this.entities.length > 0 && this.showAllRoute !== ""; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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", |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 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.$emit("entity-selected", { |
||||
|
type: "person", |
||||
|
data: person, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle project selection from ProjectCard |
||||
|
*/ |
||||
|
handleProjectSelected(project: PlanData): void { |
||||
|
this.$emit("entity-selected", { |
||||
|
type: "project", |
||||
|
data: project, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle special entity selection from SpecialEntityCard |
||||
|
*/ |
||||
|
handleEntitySelected(event: { type: string; entityType: string; data: any }): void { |
||||
|
this.$emit("entity-selected", { |
||||
|
type: "special", |
||||
|
entityType: event.entityType, |
||||
|
data: event.data, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
/* Grid-specific styles if needed */ |
||||
|
</style> |
@ -0,0 +1,75 @@ |
|||||
|
/** |
||||
|
* ShowAllCard.vue - Show All navigation card component |
||||
|
* |
||||
|
* Extracted from GiftedDialog.vue to handle "Show All" navigation |
||||
|
* for both people and projects entity types. |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
*/ |
||||
|
<template> |
||||
|
<li class="cursor-pointer"> |
||||
|
<router-link |
||||
|
:to="navigationRoute" |
||||
|
class="block text-center" |
||||
|
> |
||||
|
<font-awesome |
||||
|
icon="circle-right" |
||||
|
class="text-blue-500 text-5xl mb-1" |
||||
|
/> |
||||
|
<h3 class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"> |
||||
|
Show All |
||||
|
</h3> |
||||
|
</router-link> |
||||
|
</li> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Prop, Vue } from "vue-facing-decorator"; |
||||
|
import { RouteLocationRaw } from "vue-router"; |
||||
|
|
||||
|
/** |
||||
|
* ShowAllCard - Displays "Show All" navigation for entity grids |
||||
|
* |
||||
|
* Features: |
||||
|
* - Provides navigation to full entity listings |
||||
|
* - Supports different routes based on entity type |
||||
|
* - Maintains context through query parameters |
||||
|
* - Consistent visual styling with other cards |
||||
|
*/ |
||||
|
@Component |
||||
|
export default class ShowAllCard extends Vue { |
||||
|
/** Type of entities being shown */ |
||||
|
@Prop({ required: true }) |
||||
|
entityType!: "people" | "projects"; |
||||
|
|
||||
|
/** Route name to navigate to */ |
||||
|
@Prop({ required: true }) |
||||
|
routeName!: string; |
||||
|
|
||||
|
/** Query parameters to pass to the route */ |
||||
|
@Prop({ default: () => ({}) }) |
||||
|
queryParams!: Record<string, any>; |
||||
|
|
||||
|
/** |
||||
|
* Computed navigation route with query parameters |
||||
|
*/ |
||||
|
get navigationRoute(): RouteLocationRaw { |
||||
|
return { |
||||
|
name: this.routeName, |
||||
|
query: this.queryParams, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
/* Ensure router-link styling is consistent */ |
||||
|
a { |
||||
|
text-decoration: none; |
||||
|
} |
||||
|
|
||||
|
a:hover .fa-circle-right { |
||||
|
transform: scale(1.1); |
||||
|
transition: transform 0.2s ease; |
||||
|
} |
||||
|
</style> |
@ -0,0 +1,135 @@ |
|||||
|
/** |
||||
|
* SpecialEntityCard.vue - Special entity display component |
||||
|
* |
||||
|
* Extracted from GiftedDialog.vue to handle special entities like "You" |
||||
|
* and "Unnamed" with conflict detection and selection capability. |
||||
|
* |
||||
|
* @author Matthew Raymer |
||||
|
*/ |
||||
|
<template> |
||||
|
<li |
||||
|
:class="cardClasses" |
||||
|
@click="handleClick" |
||||
|
> |
||||
|
<font-awesome |
||||
|
:icon="icon" |
||||
|
:class="iconClasses" |
||||
|
/> |
||||
|
<h3 :class="nameClasses"> |
||||
|
{{ label }} |
||||
|
</h3> |
||||
|
</li> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Prop, Vue } from "vue-facing-decorator"; |
||||
|
|
||||
|
/** |
||||
|
* SpecialEntityCard - Displays special entities with selection capability |
||||
|
* |
||||
|
* Features: |
||||
|
* - Displays special entities like "You" and "Unnamed" |
||||
|
* - Shows appropriate FontAwesome icons |
||||
|
* - Handles conflict states and selection |
||||
|
* - Emits selection events with entity data |
||||
|
* - Configurable styling based on entity type |
||||
|
*/ |
||||
|
@Component |
||||
|
export default class SpecialEntityCard extends Vue { |
||||
|
/** Type of special entity */ |
||||
|
@Prop({ required: true }) |
||||
|
entityType!: "you" | "unnamed"; |
||||
|
|
||||
|
/** Display label for the entity */ |
||||
|
@Prop({ required: true }) |
||||
|
label!: string; |
||||
|
|
||||
|
/** FontAwesome icon name */ |
||||
|
@Prop({ required: true }) |
||||
|
icon!: string; |
||||
|
|
||||
|
/** Whether this entity can be selected */ |
||||
|
@Prop({ default: true }) |
||||
|
selectable!: boolean; |
||||
|
|
||||
|
/** Whether selecting this entity would create a conflict */ |
||||
|
@Prop({ default: false }) |
||||
|
conflicted!: boolean; |
||||
|
|
||||
|
/** Entity data to emit when selected */ |
||||
|
@Prop({ required: true }) |
||||
|
entityData!: { did?: string; name: string }; |
||||
|
|
||||
|
/** |
||||
|
* Computed CSS classes for the card container |
||||
|
*/ |
||||
|
get cardClasses(): string { |
||||
|
const baseClasses = "block"; |
||||
|
|
||||
|
if (!this.selectable || this.conflicted) { |
||||
|
return `${baseClasses} cursor-not-allowed opacity-50`; |
||||
|
} |
||||
|
|
||||
|
return `${baseClasses} cursor-pointer`; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computed CSS classes for the icon |
||||
|
*/ |
||||
|
get iconClasses(): string { |
||||
|
const baseClasses = "text-5xl mb-1"; |
||||
|
|
||||
|
if (this.conflicted) { |
||||
|
return `${baseClasses} text-slate-400`; |
||||
|
} |
||||
|
|
||||
|
// Different colors for different entity types |
||||
|
switch (this.entityType) { |
||||
|
case "you": |
||||
|
return `${baseClasses} text-blue-500`; |
||||
|
case "unnamed": |
||||
|
return `${baseClasses} text-slate-400`; |
||||
|
default: |
||||
|
return `${baseClasses} text-slate-400`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computed CSS classes for the entity name/label |
||||
|
*/ |
||||
|
get nameClasses(): string { |
||||
|
const baseClasses = "text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"; |
||||
|
|
||||
|
if (this.conflicted) { |
||||
|
return `${baseClasses} text-slate-400`; |
||||
|
} |
||||
|
|
||||
|
// Different colors for different entity types |
||||
|
switch (this.entityType) { |
||||
|
case "you": |
||||
|
return `${baseClasses} text-blue-500`; |
||||
|
case "unnamed": |
||||
|
return `${baseClasses} text-slate-500 italic`; |
||||
|
default: |
||||
|
return `${baseClasses} text-slate-500`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle card click - only emit if selectable and not conflicted |
||||
|
*/ |
||||
|
handleClick(): void { |
||||
|
if (this.selectable && !this.conflicted) { |
||||
|
this.$emit("entity-selected", { |
||||
|
type: "special", |
||||
|
entityType: this.entityType, |
||||
|
data: this.entityData, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
/* Component-specific styles if needed */ |
||||
|
</style> |
Loading…
Reference in new issue