Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/components/dashboard/entity-info-drawer/expandable-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use client'

import { useState, type FC } from 'react'
import { cn } from '@/lib/utils'

/**
* The expand/collapse pair behind every truncated list in the entity info
* drawer (GOAL-315). Before this, a list that overflowed its cap rendered a
* dead "+ N more" label: it told the user content existed but gave them no
* way to reach it.
*
* The closest precedent in the app is `active-pulses.tsx`, which flips
* between "View all" and "Show less" — except there the state is owned by
* the parent route. Here each drawer section owns its own.
*/

/** Collapse state for one truncated list. */
export function useExpandableList<T>(items: T[], limit: number) {
const [expanded, setExpanded] = useState(false)
return {
visible: expanded ? items : items.slice(0, limit),
hiddenCount: Math.max(0, items.length - limit),
expanded,
toggle: () => setExpanded((prev) => !prev),
}
}

/**
* Interactive "+ N more" / "Show less" toggle. The visible string is the
* accessible name (WCAG 2.5.3 Label in Name) with the item noun appended
* off-screen, so "click show less" spoken by a voice-input user matches.
*/
export const ShowMoreToggle: FC<{
expanded: boolean
hiddenCount: number
onToggle: () => void
/** Plural noun appended to the accessible name, e.g. "pulses". */
itemLabel: string
className?: string
}> = ({ expanded, hiddenCount, onToggle, itemLabel, className }) => (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className={cn(
'inline-flex items-center gap-1 rounded-lg px-3 py-1.5',
'text-[11px] font-semibold text-gp-ink-muted dark:text-white/55',
'hover:text-gp-primary hover:bg-gp-primary/10',
'dark:hover:text-gp-primary dark:hover:bg-gp-primary/15',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-gp-primary/50',
'transition-all cursor-pointer',
className
)}
>
<span
className="material-symbols-outlined text-[16px] leading-none"
aria-hidden="true"
>
{expanded ? 'expand_less' : 'expand_more'}
</span>
{expanded ? 'Show less' : `+ ${hiddenCount} more`}
<span className="sr-only">{` ${itemLabel}`}</span>
</button>
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@ import {
Users,
Waves,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { GET_FIELD_CONTEXT_DETAILS } from '@/app/graphql/queries/FIELD_CONTEXT_DETAILS_QUERIES'
import { GET_FIELD_CONTEXT_PEOPLE } from '@/app/graphql/queries/FIELD_CONTEXT_PEOPLE_QUERIES'
import { GET_DOCUMENTS_BY_FIELD_CONTEXT } from '@/app/graphql/queries/DOCUMENT_QUERIES'
import {
UPDATE_FIELD_CONTEXT_MUTATION,
LOG_FIELD_ACTIVITY,
} from '@/app/graphql/mutations'
import { formatResonanceLabel } from '@/utils/graph-utils'
import {
BodySkeleton,
EditCta,
Expand All @@ -29,9 +27,16 @@ import {
ErrorBody,
NotFoundBody,
PrimaryCta,
SectionHeader,
StatCell,
} from './shared'
import {
DocumentsSection,
PeopleSection,
PulsesSection,
ResonancesSection,
type FieldContextDocument,
type FieldContextPerson,
} from './field-context-sections'
import { dispatchOpenInfoDrawer } from './types'

/**
Expand Down Expand Up @@ -61,26 +66,15 @@ export const FieldContextDetailsBody: FC<{
const { data: peopleData } = useQuery<{
fieldContexts?: {
id: string
people?: {
id: string
name: string | null
firstName: string | null
lastName: string | null
email: string | null
photo: string | null
}[]
people?: FieldContextPerson[]
}[]
}>(GET_FIELD_CONTEXT_PEOPLE, {
variables: { contextId },
fetchPolicy: 'cache-and-network',
})

const { data: docsData } = useQuery<{
documentsByFieldContext?: {
id: string
filename: string
uploadedAt: string
}[]
documentsByFieldContext?: FieldContextDocument[]
}>(GET_DOCUMENTS_BY_FIELD_CONTEXT, {
variables: { fieldContextId: contextId },
fetchPolicy: 'cache-and-network',
Expand Down Expand Up @@ -237,163 +231,13 @@ export const FieldContextDetailsBody: FC<{
/>
</section>

{allPulses.length > 0 && (
<section className="px-6 pb-5">
<SectionHeader>Recent pulses ({allPulses.length})</SectionHeader>
<ul className="mt-2 space-y-1.5">
{allPulses.slice(0, 8).map((pulse) => (
<li key={pulse.id}>
<button
type="button"
onClick={() =>
dispatchOpenInfoDrawer({
type: 'Pulse',
id: pulse.id,
label: pulse.title ?? undefined,
})
}
className="group w-full text-left rounded-xl border border-gp-glass-border bg-white/5 dark:bg-white/[0.03] hover:bg-white/10 dark:hover:bg-white/[0.06] hover:border-white/20 px-3.5 py-2.5 transition-all cursor-pointer flex items-center gap-3"
>
<span className="text-[9px] font-bold uppercase tracking-wider text-gp-ink-muted dark:text-white/45 w-14 shrink-0">
{typenameLabel(pulse.__typename)}
</span>
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-gp-ink-strong dark:text-white/90 truncate">
{pulse.title || 'Untitled'}
</p>
</div>
<ArrowRight className="w-3.5 h-3.5 text-white/30 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all" />
</button>
</li>
))}
{allPulses.length > 8 && (
<li className="text-[11px] text-gp-ink-muted dark:text-white/45 px-3 pt-1">
+ {allPulses.length - 8} more
</li>
)}
</ul>
</section>
)}
<PulsesSection pulses={allPulses} />

{resonances.length > 0 && (
<section className="px-6 pb-5">
<SectionHeader>Resonances ({resonances.length})</SectionHeader>
<ul className="mt-2 space-y-1.5">
{resonances.slice(0, 6).map((res) => {
const src = res.source?.[0]
const tgt = res.target?.[0]
const srcTitle =
(src && 'title' in src ? src.title : undefined) ?? 'Pulse'
const tgtTitle =
(tgt && 'title' in tgt ? tgt.title : undefined) ?? 'Pulse'
return (
<li key={res.id}>
<button
type="button"
onClick={() =>
dispatchOpenInfoDrawer({
type: 'ResonanceLink',
id: res.id,
label: res.label ?? undefined,
})
}
className={cn(
'group w-full text-left rounded-xl border border-gp-glass-border bg-white/5 dark:bg-white/[0.03]',
'hover:bg-white/10 dark:hover:bg-white/[0.06] hover:border-white/20',
'px-3.5 py-2.5 transition-all cursor-pointer'
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-gp-primary truncate">
{formatResonanceLabel(res.label ?? null)}
</span>
<ArrowRight className="w-3.5 h-3.5 text-white/30 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all shrink-0" />
</div>
<p className="mt-1 text-[11px] text-gp-ink-muted dark:text-white/55 truncate">
{srcTitle} ↔ {tgtTitle}
</p>
</button>
</li>
)
})}
{resonances.length > 6 && (
<li className="text-[11px] text-gp-ink-muted dark:text-white/45 px-3 pt-1">
+ {resonances.length - 6} more
</li>
)}
</ul>
</section>
)}
<ResonancesSection resonances={resonances} />

{people.length > 0 && (
<section className="px-6 pb-5">
<SectionHeader>People ({people.length})</SectionHeader>
<ul className="mt-2 grid grid-cols-2 gap-1.5">
{people.slice(0, 8).map((person) => {
const name =
person.name?.trim() ||
`${person.firstName ?? ''} ${person.lastName ?? ''}`.trim() ||
'Member'
return (
<li key={person.id}>
<button
type="button"
onClick={() =>
dispatchOpenInfoDrawer({
type: 'Person',
id: person.id,
label: name,
})
}
className="group w-full text-left rounded-lg border border-gp-glass-border bg-white/5 dark:bg-white/[0.03] hover:bg-white/10 dark:hover:bg-white/[0.06] hover:border-white/20 px-2 py-1.5 transition-all cursor-pointer flex items-center gap-2"
>
<div className="size-6 shrink-0 rounded-full bg-white/10 border border-white/10 flex items-center justify-center text-[9px] font-bold text-white/80">
{name.slice(0, 2).toUpperCase()}
</div>
<span className="text-[11px] font-medium text-gp-ink-strong dark:text-white/85 truncate">
{name}
</span>
</button>
</li>
)
})}
</ul>
</section>
)}
<PeopleSection people={people} />

{documents.length > 0 && (
<section className="px-6 pb-5">
<SectionHeader>Documents ({documents.length})</SectionHeader>
<ul className="mt-2 space-y-1.5">
{documents.slice(0, 5).map((doc) => (
<li key={doc.id}>
<button
type="button"
onClick={() =>
dispatchOpenInfoDrawer({
type: 'Document',
id: doc.id,
label: doc.filename,
})
}
className="group w-full text-left rounded-xl border border-gp-glass-border bg-white/5 dark:bg-white/[0.03] hover:bg-white/10 dark:hover:bg-white/[0.06] hover:border-white/20 px-3.5 py-2.5 transition-all cursor-pointer flex items-center gap-3"
>
<FileText className="w-3.5 h-3.5 text-gp-ink-muted dark:text-white/55 shrink-0" />
<span className="text-xs font-medium text-gp-ink-strong dark:text-white/85 truncate flex-1">
{doc.filename}
</span>
<ArrowRight className="w-3.5 h-3.5 text-white/30 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all" />
</button>
</li>
))}
{documents.length > 5 && (
<li className="text-[11px] text-gp-ink-muted dark:text-white/45 px-3 pt-1">
+ {documents.length - 5} more
</li>
)}
</ul>
</section>
)}
<DocumentsSection documents={documents} />

<footer className="mt-auto px-6 py-5 border-t border-gp-glass-border bg-white/[0.02] dark:bg-white/[0.02]">
{isEditMode ? (
Expand All @@ -420,20 +264,3 @@ export const FieldContextDetailsBody: FC<{
</div>
)
}

function typenameLabel(typename: string | null | undefined): string {
switch (typename) {
case 'GoalPulse':
return 'Goal'
case 'ResourcePulse':
return 'Resource'
case 'StoryPulse':
return 'Story'
case 'CarePulse':
return 'Care'
case 'CoreValuePulse':
return 'Value'
default:
return 'Pulse'
}
}
Loading