Skip to content

Commit 55186c8

Browse files
authored
feat: display contact metadata in chat info panel (#155)
* feat: display contact metadata in chat info panel Rename custom_fields to metadata in the API response and frontend store, add a collapsible MetadataSection component that renders key-value pairs, tables for arrays of objects, and badges for primitive arrays. * test: add e2e tests for contact metadata panel - Add updateContact helper to ApiHelper - Add id="info-button" to ChatView info button for reliable test selector - Test metadata display: primitives, nested objects, arrays, booleans - Test collapse/expand, label formatting, and empty metadata state * fix: clean up contact info panel border spacing - Remove double border between Tags and metadata sections - Add border above "No data configured" empty state - Increase gap between metadata sections for readability * docs: document contact metadata display in info panel
1 parent f1cd0e0 commit 55186c8

File tree

9 files changed

+483
-9
lines changed

9 files changed

+483
-9
lines changed

docs/src/content/docs/api-reference/contacts.mdx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,86 @@ To unassign a contact, set `user_id` to `null`:
218218
```
219219

220220
<Aside type="tip">
221-
Use the `metadata` field to store custom data like customer IDs, order numbers, or any business-specific information.
221+
Use the `metadata` field to store custom data like customer IDs, order numbers, or any business-specific information. Metadata is displayed automatically in the **Contact Info** panel in the chat view.
222+
</Aside>
223+
224+
## Contact Metadata
225+
226+
The `metadata` field is a freeform JSON object that can hold any structured data. It is displayed in the Contact Info panel alongside tags and session data.
227+
228+
### Supported Data Types
229+
230+
Different value types are rendered differently in the panel:
231+
232+
| Type | Display |
233+
|------|---------|
234+
| String, number | Key-value row |
235+
| Boolean | `Yes` / `No` badge |
236+
| Object | Collapsible section with key-value rows |
237+
| Array of objects | Collapsible table with column headers |
238+
| Array of primitives | Inline badges |
239+
240+
### Example
241+
242+
```json
243+
{
244+
"metadata": {
245+
"plan": "premium",
246+
"age": 30,
247+
"active": true,
248+
"address": {
249+
"city": "Mumbai",
250+
"state": "Maharashtra",
251+
"zip": "400001"
252+
},
253+
"orders": [
254+
{ "id": "ORD-001", "amount": 1500, "status": "delivered" },
255+
{ "id": "ORD-002", "amount": 2300, "status": "pending" }
256+
],
257+
"interests": ["fitness", "tech", "travel"]
258+
}
259+
}
260+
```
261+
262+
This renders as:
263+
264+
- **General** section — `plan`, `age`, and `active` as key-value rows (boolean shown as a badge)
265+
- **Address** section — collapsible key-value pairs for `city`, `state`, `zip`
266+
- **Orders** section — collapsible table with `Id`, `Amount`, `Status` columns
267+
- **Interests** section — inline badges: `fitness`, `tech`, `travel`
268+
269+
### Nesting Depth
270+
271+
The panel supports **one level of nesting**. Top-level keys are organized as follows:
272+
273+
| Top-level value | Rendered as |
274+
|-----------------|-------------|
275+
| Primitive (string, number, boolean) | Row in the **General** section |
276+
| Object | Its own collapsible section with key-value rows |
277+
| Array of objects | Its own collapsible section with a table |
278+
| Array of primitives | Its own collapsible section with badges |
279+
280+
Values **inside** a nested object or array are always displayed as flat text. If a nested object contains another object, the inner value is shown as a raw JSON string. For example:
281+
282+
```json
283+
{
284+
"metadata": {
285+
"address": {
286+
"city": "Mumbai",
287+
"location": { "lat": 19.07, "lng": 72.87 }
288+
}
289+
}
290+
}
291+
```
292+
293+
Here `city` displays as `Mumbai`, but `location` displays as `{"lat":19.07,"lng":72.87}`.
294+
295+
<Aside type="caution">
296+
Keep metadata **flat or one level deep** for the best display. Deeply nested structures will fall back to raw JSON strings in the panel.
297+
</Aside>
298+
299+
<Aside type="note">
300+
Keys are automatically formatted for display: `snake_case` and `camelCase` are converted to title case (e.g. `first_name` → "First Name", `lastName` → "Last Name").
222301
</Aside>
223302

224303
## Get Session Data

frontend/e2e/helpers/api.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,18 @@ export class ApiHelper {
286286
return data.data?.contacts || []
287287
}
288288

289+
async updateContact(contactId: string, data: Record<string, any>): Promise<any> {
290+
const response = await this.request.put(`${BASE_URL}/api/contacts/${contactId}`, {
291+
headers: this.headers,
292+
data
293+
})
294+
if (!response.ok()) {
295+
throw new Error(`Failed to update contact: ${await response.text()}`)
296+
}
297+
const result = await response.json()
298+
return result.data
299+
}
300+
289301
// Conversation Notes
290302
async listNotes(contactId: string): Promise<any[]> {
291303
const response = await this.request.get(`${BASE_URL}/api/contacts/${contactId}/notes`, {
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { test, expect, request as playwrightRequest } from '@playwright/test'
2+
import { loginAsAdmin } from '../../helpers'
3+
import { ApiHelper } from '../../helpers/api'
4+
import { ChatPage } from '../../pages'
5+
6+
const TEST_METADATA = {
7+
plan: 'premium',
8+
age: 30,
9+
active: true,
10+
address: {
11+
city: 'Mumbai',
12+
state: 'Maharashtra',
13+
zip: '400001',
14+
},
15+
orders: [
16+
{ id: 'ORD-001', amount: 1500, status: 'delivered' },
17+
{ id: 'ORD-002', amount: 2300, status: 'pending' },
18+
],
19+
interests: ['fitness', 'tech', 'travel'],
20+
}
21+
22+
test.describe('Contact Metadata Panel', () => {
23+
test.describe.configure({ mode: 'serial' }) // Tests share contact metadata state
24+
25+
let contactId: string
26+
27+
test.beforeAll(async () => {
28+
const reqContext = await playwrightRequest.newContext()
29+
const api = new ApiHelper(reqContext)
30+
await api.loginAsAdmin()
31+
32+
// Get existing contacts or create one
33+
let contacts = await api.getContacts()
34+
if (contacts.length === 0) {
35+
await api.createContact(`91${Date.now().toString().slice(-10)}`, 'Metadata Test')
36+
contacts = await api.getContacts()
37+
}
38+
39+
contactId = contacts[0].id
40+
41+
// Set metadata on the contact
42+
await api.updateContact(contactId, { metadata: TEST_METADATA })
43+
await reqContext.dispose()
44+
})
45+
46+
let chatPage: ChatPage
47+
48+
test.beforeEach(async ({ page }) => {
49+
await loginAsAdmin(page)
50+
chatPage = new ChatPage(page)
51+
})
52+
53+
async function openInfoPanel(page: import('@playwright/test').Page) {
54+
await chatPage.goto(contactId)
55+
56+
// Open contact info panel
57+
const infoBtn = page.locator('#info-button')
58+
await infoBtn.click()
59+
60+
// Wait for the panel header to appear
61+
await expect(page.getByText('Contact Info')).toBeVisible({ timeout: 10000 })
62+
}
63+
64+
test('should display metadata section when contact has metadata', async ({ page }) => {
65+
await openInfoPanel(page)
66+
67+
// "General" section should show for top-level primitives (plan, age, active)
68+
await expect(page.getByText('General')).toBeVisible()
69+
70+
// Verify primitive values are displayed
71+
await expect(page.getByText('premium')).toBeVisible()
72+
await expect(page.getByText('30', { exact: true })).toBeVisible()
73+
})
74+
75+
test('should display nested object metadata as key-value pairs', async ({ page }) => {
76+
await openInfoPanel(page)
77+
78+
// "Address" section header (formatted from "address")
79+
await expect(page.getByText('Address')).toBeVisible()
80+
81+
// Key-value pairs inside the address section
82+
await expect(page.getByText('Mumbai')).toBeVisible()
83+
await expect(page.getByText('Maharashtra')).toBeVisible()
84+
await expect(page.getByText('400001')).toBeVisible()
85+
})
86+
87+
test('should display array of objects as a table', async ({ page }) => {
88+
await openInfoPanel(page)
89+
90+
// "Orders" section header (formatted from "orders")
91+
await expect(page.getByText('Orders')).toBeVisible()
92+
93+
// Table should show the count
94+
await expect(page.getByText('(2)')).toBeVisible()
95+
96+
// Table column headers
97+
await expect(page.locator('th').getByText('Id')).toBeVisible()
98+
await expect(page.locator('th').getByText('Amount')).toBeVisible()
99+
await expect(page.locator('th').getByText('Status')).toBeVisible()
100+
101+
// Table data
102+
await expect(page.getByText('ORD-001')).toBeVisible()
103+
await expect(page.getByText('1500')).toBeVisible()
104+
await expect(page.getByText('delivered')).toBeVisible()
105+
await expect(page.getByText('ORD-002')).toBeVisible()
106+
})
107+
108+
test('should display array of primitives as badges', async ({ page }) => {
109+
await openInfoPanel(page)
110+
111+
// "Interests" section header
112+
await expect(page.getByText('Interests')).toBeVisible()
113+
114+
// Badges for each interest
115+
await expect(page.getByText('fitness')).toBeVisible()
116+
await expect(page.getByText('tech')).toBeVisible()
117+
await expect(page.getByText('travel')).toBeVisible()
118+
})
119+
120+
test('should display boolean metadata as Yes/No badges', async ({ page }) => {
121+
await openInfoPanel(page)
122+
123+
// The "Active" label should appear in the General section
124+
await expect(page.getByText('Active')).toBeVisible()
125+
126+
// Boolean true should show as "Yes" badge
127+
await expect(page.getByText('Yes')).toBeVisible()
128+
})
129+
130+
test('should collapse and expand metadata sections', async ({ page }) => {
131+
await openInfoPanel(page)
132+
133+
// Click "Address" section header to collapse it
134+
const addressTrigger = page.locator('button').filter({ hasText: 'Address' })
135+
await addressTrigger.click()
136+
137+
// Values should be hidden after collapse
138+
await expect(page.getByText('Mumbai')).toBeHidden()
139+
140+
// Click again to expand
141+
await addressTrigger.click()
142+
143+
// Values should be visible again
144+
await expect(page.getByText('Mumbai')).toBeVisible()
145+
})
146+
147+
test('should format metadata labels from snake_case and camelCase', async ({ page }) => {
148+
// Update contact with snake_case and camelCase keys
149+
const reqContext = await playwrightRequest.newContext()
150+
const api = new ApiHelper(reqContext)
151+
await api.loginAsAdmin()
152+
await api.updateContact(contactId, {
153+
metadata: {
154+
first_name: 'John',
155+
lastName: 'Doe',
156+
},
157+
})
158+
await reqContext.dispose()
159+
160+
await openInfoPanel(page)
161+
162+
// snake_case "first_name" should become "First Name"
163+
await expect(page.getByText('First Name')).toBeVisible()
164+
165+
// camelCase "lastName" should become "Last Name"
166+
await expect(page.getByText('Last Name')).toBeVisible()
167+
168+
// Restore original metadata for other tests
169+
const restoreCtx = await playwrightRequest.newContext()
170+
const restoreApi = new ApiHelper(restoreCtx)
171+
await restoreApi.loginAsAdmin()
172+
await restoreApi.updateContact(contactId, { metadata: TEST_METADATA })
173+
await restoreCtx.dispose()
174+
})
175+
176+
test('should not show metadata section when contact has no metadata', async ({ page }) => {
177+
// Clear metadata
178+
const reqContext = await playwrightRequest.newContext()
179+
const api = new ApiHelper(reqContext)
180+
await api.loginAsAdmin()
181+
await api.updateContact(contactId, { metadata: {} })
182+
await reqContext.dispose()
183+
184+
await openInfoPanel(page)
185+
186+
// "General" section from metadata should not be visible
187+
await expect(page.getByText('General')).toBeHidden()
188+
189+
// Restore metadata
190+
const restoreCtx = await playwrightRequest.newContext()
191+
const restoreApi = new ApiHelper(restoreCtx)
192+
await restoreApi.loginAsAdmin()
193+
await restoreApi.updateContact(contactId, { metadata: TEST_METADATA })
194+
await restoreCtx.dispose()
195+
})
196+
})

frontend/src/components/chat/ContactInfoPanel.vue

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
} from '@/components/ui/command'
2525
import { X, ChevronDown, Phone, User, Plus, Check, Tags, Loader2 } from 'lucide-vue-next'
2626
import { TagBadge } from '@/components/ui/tag-badge'
27-
import { getInitials, getAvatarGradient } from '@/lib/utils'
27+
import MetadataSection from '@/components/chat/MetadataSection.vue'
28+
import { getInitials, getAvatarGradient, formatLabel } from '@/lib/utils'
2829
import { getTagColorClass } from '@/lib/constants'
2930
import { useTagsStore } from '@/stores/tags'
3031
import { useAuthStore } from '@/stores/auth'
@@ -174,6 +175,26 @@ const contactTags = computed(() => {
174175
return props.contact.tags as string[]
175176
})
176177
178+
// Contact metadata
179+
const hasMetadata = computed(() => {
180+
const md = props.contact.metadata
181+
return md && typeof md === 'object' && Object.keys(md).length > 0
182+
})
183+
184+
const metadataPrimitives = computed(() => {
185+
if (!hasMetadata.value) return []
186+
return Object.entries(props.contact.metadata).filter(
187+
([, v]) => v === null || typeof v !== 'object'
188+
)
189+
})
190+
191+
const metadataSections = computed(() => {
192+
if (!hasMetadata.value) return []
193+
return Object.entries(props.contact.metadata).filter(
194+
([, v]) => v !== null && typeof v === 'object'
195+
)
196+
})
197+
177198
// Get tag details for display
178199
function getTagDetails(tagName: string): Tag | undefined {
179200
return tagsStore.getTagByName(tagName)
@@ -264,7 +285,7 @@ async function updateContactTags(tags: string[]) {
264285
</div>
265286

266287
<!-- Tags Section (always shown) -->
267-
<div class="border-b pb-4">
288+
<div class="pb-4">
268289
<div class="flex items-center justify-between py-2">
269290
<h5 class="text-sm font-medium flex items-center gap-2">
270291
<Tags class="h-4 w-4 text-muted-foreground" />
@@ -330,8 +351,25 @@ async function updateContactTags(tags: string[]) {
330351
</div>
331352
</div>
332353

354+
<!-- Contact Metadata -->
355+
<div v-if="hasMetadata" class="space-y-3">
356+
<!-- General section: top-level primitives -->
357+
<MetadataSection
358+
v-if="metadataPrimitives.length > 0"
359+
label="General"
360+
:data="Object.fromEntries(metadataPrimitives)"
361+
/>
362+
<!-- Object / array sections -->
363+
<MetadataSection
364+
v-for="[key, val] in metadataSections"
365+
:key="key"
366+
:label="formatLabel(key)"
367+
:data="val"
368+
/>
369+
</div>
370+
333371
<!-- No Session Data or no panel config -->
334-
<div v-if="!props.sessionData || sortedSections.length === 0" class="text-center py-6 text-muted-foreground">
372+
<div v-if="!props.sessionData || sortedSections.length === 0" class="text-center py-6 text-muted-foreground border-t">
335373
<User class="h-8 w-8 mx-auto mb-2 opacity-50" />
336374
<p class="text-sm">No data configured</p>
337375
<p class="text-xs mt-1">Configure panel display in the chatbot flow settings.</p>

0 commit comments

Comments
 (0)