feat: add tag discovery analytics and reporting
This commit is contained in:
@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
||||
{!loading &&
|
||||
results.map((tag) => {
|
||||
const isSelected = selected.some((t) => t.id === tag.id)
|
||||
const recentClicks = Number(tag.recent_clicks || 0)
|
||||
const usageCount = Number(tag.usage_count || 0)
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
||||
/>
|
||||
{tag.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import BulkTagModal from './BulkTagModal'
|
||||
|
||||
describe('BulkTagModal', () => {
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
|
||||
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
const requestUrl = String(url)
|
||||
|
||||
if (requestUrl.includes('?q=high')) {
|
||||
return {
|
||||
json: async () => ([
|
||||
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
json: async () => ([
|
||||
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||
]),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
document.head.innerHTML = ''
|
||||
})
|
||||
|
||||
it('shows recent click momentum for initial results', async () => {
|
||||
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns selected tag ids and shows recent click momentum in search results', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search tags…')
|
||||
await userEvent.type(input, 'high')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith([2])
|
||||
})
|
||||
})
|
||||
@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
|
||||
key: item?.id ?? tag,
|
||||
label: item?.name || item?.tag || item?.slug || tag,
|
||||
tag,
|
||||
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
|
||||
usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null,
|
||||
recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0,
|
||||
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
|
||||
}
|
||||
})
|
||||
@@ -173,6 +174,9 @@ function SuggestionDropdown({
|
||||
|
||||
{!loading && !error && suggestions.map((item, index) => {
|
||||
const active = highlightedIndex === index
|
||||
const detailLabel = item.recentClicks > 0
|
||||
? `${item.recentClicks.toLocaleString()} recent`
|
||||
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
@@ -192,8 +196,8 @@ function SuggestionDropdown({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{typeof item.usageCount === 'number' && (
|
||||
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
|
||||
{detailLabel && (
|
||||
<span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -26,8 +26,8 @@ describe('TagInput', () => {
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
|
||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
|
||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
|
||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -74,6 +74,17 @@ describe('TagInput', () => {
|
||||
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows recent click momentum when suggestions provide it', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Tag input')
|
||||
await userEvent.type(input, 'city')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('14 recent')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('supports comma-separated paste', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
|
||||
@@ -37,15 +37,18 @@ function toListItem(item) {
|
||||
if (!item) return null
|
||||
if (typeof item === 'string') {
|
||||
const slug = normalizeSlug(item)
|
||||
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
|
||||
return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null
|
||||
}
|
||||
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
|
||||
if (!slug) return null
|
||||
const usageCount = Number(item.usage_count)
|
||||
const recentClicks = Number(item.recent_clicks)
|
||||
return {
|
||||
key: String(item.id ?? slug),
|
||||
slug,
|
||||
name: item.name || item.tag || item.slug || slug,
|
||||
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
|
||||
usageCount: Number.isFinite(usageCount) ? usageCount : null,
|
||||
recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0,
|
||||
isAi: Boolean(item.is_ai || item.source === 'ai'),
|
||||
}
|
||||
}
|
||||
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
|
||||
}
|
||||
|
||||
function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||
const detailLabel = item.recentClicks > 0
|
||||
? `${item.recentClicks.toLocaleString()} recent clicks`
|
||||
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||
)}
|
||||
</span>
|
||||
|
||||
{typeof item.usageCount === 'number' && (
|
||||
{detailLabel && (
|
||||
<span className="ml-3 shrink-0 text-[11px] text-white/40">
|
||||
{item.usageCount.toLocaleString()} uses
|
||||
{detailLabel}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import TagPicker from './TagPicker'
|
||||
|
||||
function Harness({ initial = [] }) {
|
||||
const [tags, setTags] = React.useState(initial)
|
||||
|
||||
return (
|
||||
<TagPicker
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
suggestedTags={['sunset']}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TagPicker', () => {
|
||||
beforeEach(() => {
|
||||
window.axios = {
|
||||
get: vi.fn(async (url) => {
|
||||
if (url.startsWith('/api/tags/search')) {
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||
],
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows recent click momentum for popular tags on mount', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||
})
|
||||
|
||||
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows recent click momentum in search results and lets the user select a tag', async () => {
|
||||
render(<Harness />)
|
||||
|
||||
const input = screen.getByLabelText('Search or add tags')
|
||||
await userEvent.type(input, 'high')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||
|
||||
expect(screen.getByText('High Contrast')).not.toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user