@startsimpli/ui 0.4.9 → 0.4.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Sparkles, AlertCircle, CheckCircle2, MinusCircle } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
} from '../ui/dialog'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal subset of the API client surface this component depends on.
|
|
16
|
+
* Lets consumers pass either the full `EnrichmentApi` from `@startsimpli/api`
|
|
17
|
+
* or a custom adapter — keeps this component decoupled from the singleton.
|
|
18
|
+
*/
|
|
19
|
+
export interface ApolloEnrichmentClient {
|
|
20
|
+
enrichApollo(contactIds: string[]): Promise<ApolloEnrichmentSummary>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mirrors `ApolloEnrichmentSummary` from @startsimpli/api. */
|
|
24
|
+
export interface ApolloEnrichmentSummary {
|
|
25
|
+
total: number
|
|
26
|
+
enriched: string[]
|
|
27
|
+
skipped: Array<{ contact_id: string; reason: string }>
|
|
28
|
+
errors: Array<{ contact_id: string; error: string }>
|
|
29
|
+
missing: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ApolloEnrichButtonProps {
|
|
33
|
+
/** Contact ids to enrich. Disabled when empty. */
|
|
34
|
+
contactIds: string[]
|
|
35
|
+
/** API client with `enrichApollo`. Pass `api.enrichment` from @startsimpli/api. */
|
|
36
|
+
enrichmentApi: ApolloEnrichmentClient
|
|
37
|
+
/** Called once the API call resolves (success or with per-entry errors). */
|
|
38
|
+
onComplete?: (summary: ApolloEnrichmentSummary) => void
|
|
39
|
+
/** Called when the dialog is closed (regardless of completion). */
|
|
40
|
+
onClose?: () => void
|
|
41
|
+
label?: string
|
|
42
|
+
size?: 'sm' | 'md'
|
|
43
|
+
className?: string
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Phase =
|
|
48
|
+
| { kind: 'idle' }
|
|
49
|
+
| { kind: 'running' }
|
|
50
|
+
| { kind: 'done'; summary: ApolloEnrichmentSummary }
|
|
51
|
+
| { kind: 'error'; message: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Trigger Apollo enrichment for a set of contacts. Opens a dialog showing
|
|
55
|
+
* progress + the per-bucket summary (enriched / skipped / errors / missing).
|
|
56
|
+
*
|
|
57
|
+
* App-agnostic — works for any consumer that can supply an
|
|
58
|
+
* `ApolloEnrichmentClient`.
|
|
59
|
+
*/
|
|
60
|
+
export function ApolloEnrichButton({
|
|
61
|
+
contactIds,
|
|
62
|
+
enrichmentApi,
|
|
63
|
+
onComplete,
|
|
64
|
+
onClose,
|
|
65
|
+
label = 'Enrich with Apollo',
|
|
66
|
+
size = 'sm',
|
|
67
|
+
className = '',
|
|
68
|
+
disabled = false,
|
|
69
|
+
}: ApolloEnrichButtonProps) {
|
|
70
|
+
const [open, setOpen] = React.useState(false)
|
|
71
|
+
const [phase, setPhase] = React.useState<Phase>({ kind: 'idle' })
|
|
72
|
+
|
|
73
|
+
const sizeClasses =
|
|
74
|
+
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-4 py-2 text-sm'
|
|
75
|
+
const iconClasses = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
|
|
76
|
+
|
|
77
|
+
const isEmpty = contactIds.length === 0
|
|
78
|
+
const isRunning = phase.kind === 'running'
|
|
79
|
+
const buttonDisabled = disabled || isEmpty || isRunning
|
|
80
|
+
|
|
81
|
+
const handleClick = async () => {
|
|
82
|
+
setOpen(true)
|
|
83
|
+
setPhase({ kind: 'running' })
|
|
84
|
+
try {
|
|
85
|
+
const summary = await enrichmentApi.enrichApollo(contactIds)
|
|
86
|
+
setPhase({ kind: 'done', summary })
|
|
87
|
+
onComplete?.(summary)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setPhase({
|
|
90
|
+
kind: 'error',
|
|
91
|
+
message: err instanceof Error ? err.message : 'Enrichment failed',
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handleOpenChange = (next: boolean) => {
|
|
97
|
+
setOpen(next)
|
|
98
|
+
if (!next) {
|
|
99
|
+
onClose?.()
|
|
100
|
+
// Reset phase after the dialog finishes closing so the next click
|
|
101
|
+
// starts fresh, but keep the result visible until then.
|
|
102
|
+
setTimeout(() => setPhase({ kind: 'idle' }), 200)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={handleClick}
|
|
111
|
+
disabled={buttonDisabled}
|
|
112
|
+
aria-label={label}
|
|
113
|
+
className={`flex items-center gap-1.5 font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${sizeClasses} ${className}`}
|
|
114
|
+
>
|
|
115
|
+
<Sparkles className={iconClasses} />
|
|
116
|
+
{isRunning ? 'Enriching…' : label}
|
|
117
|
+
{!isEmpty && (
|
|
118
|
+
<span className="ml-1 text-[0.65rem] opacity-75">
|
|
119
|
+
({contactIds.length})
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
125
|
+
<DialogContent>
|
|
126
|
+
<DialogHeader>
|
|
127
|
+
<DialogTitle>Apollo enrichment</DialogTitle>
|
|
128
|
+
<DialogDescription>
|
|
129
|
+
{phase.kind === 'running'
|
|
130
|
+
? `Enriching ${contactIds.length} contact${contactIds.length === 1 ? '' : 's'}…`
|
|
131
|
+
: phase.kind === 'done'
|
|
132
|
+
? 'Apollo enrichment complete.'
|
|
133
|
+
: phase.kind === 'error'
|
|
134
|
+
? 'Apollo enrichment failed.'
|
|
135
|
+
: ''}
|
|
136
|
+
</DialogDescription>
|
|
137
|
+
</DialogHeader>
|
|
138
|
+
|
|
139
|
+
<div className="space-y-3 py-2">
|
|
140
|
+
{phase.kind === 'running' && (
|
|
141
|
+
<div role="status" aria-live="polite" className="text-sm text-gray-600">
|
|
142
|
+
Reaching Apollo for {contactIds.length} contact
|
|
143
|
+
{contactIds.length === 1 ? '' : 's'}. This may take a few seconds.
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{phase.kind === 'error' && (
|
|
148
|
+
<div role="alert" className="flex items-start gap-2 text-sm text-red-700">
|
|
149
|
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
150
|
+
<span>{phase.message}</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{phase.kind === 'done' && (
|
|
155
|
+
<ApolloEnrichmentSummaryView summary={phase.summary} />
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<DialogFooter>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => handleOpenChange(false)}
|
|
163
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
|
164
|
+
>
|
|
165
|
+
{phase.kind === 'running' ? 'Hide' : 'Done'}
|
|
166
|
+
</button>
|
|
167
|
+
</DialogFooter>
|
|
168
|
+
</DialogContent>
|
|
169
|
+
</Dialog>
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ApolloEnrichmentSummaryView({
|
|
175
|
+
summary,
|
|
176
|
+
}: {
|
|
177
|
+
summary: ApolloEnrichmentSummary
|
|
178
|
+
}) {
|
|
179
|
+
const enrichedCount = summary.enriched.length
|
|
180
|
+
const skippedCount = summary.skipped.length
|
|
181
|
+
const errorCount = summary.errors.length
|
|
182
|
+
const missingCount = summary.missing.length
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="space-y-3 text-sm">
|
|
186
|
+
<div className="grid grid-cols-2 gap-2">
|
|
187
|
+
<Stat
|
|
188
|
+
icon={<CheckCircle2 className="w-4 h-4 text-green-600" />}
|
|
189
|
+
label="Enriched"
|
|
190
|
+
value={enrichedCount}
|
|
191
|
+
/>
|
|
192
|
+
<Stat
|
|
193
|
+
icon={<MinusCircle className="w-4 h-4 text-gray-500" />}
|
|
194
|
+
label="Skipped"
|
|
195
|
+
value={skippedCount}
|
|
196
|
+
/>
|
|
197
|
+
<Stat
|
|
198
|
+
icon={<AlertCircle className="w-4 h-4 text-red-600" />}
|
|
199
|
+
label="Errored"
|
|
200
|
+
value={errorCount}
|
|
201
|
+
/>
|
|
202
|
+
<Stat
|
|
203
|
+
icon={<MinusCircle className="w-4 h-4 text-amber-600" />}
|
|
204
|
+
label="Not accessible"
|
|
205
|
+
value={missingCount}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{summary.skipped.length > 0 && (
|
|
210
|
+
<DetailsList
|
|
211
|
+
title="Skipped reasons"
|
|
212
|
+
items={summary.skipped.map((s) => ({
|
|
213
|
+
id: s.contact_id,
|
|
214
|
+
text: s.reason,
|
|
215
|
+
}))}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
{summary.errors.length > 0 && (
|
|
219
|
+
<DetailsList
|
|
220
|
+
title="Errors"
|
|
221
|
+
items={summary.errors.map((e) => ({
|
|
222
|
+
id: e.contact_id,
|
|
223
|
+
text: e.error,
|
|
224
|
+
}))}
|
|
225
|
+
tone="error"
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function Stat({
|
|
233
|
+
icon,
|
|
234
|
+
label,
|
|
235
|
+
value,
|
|
236
|
+
}: {
|
|
237
|
+
icon: React.ReactNode
|
|
238
|
+
label: string
|
|
239
|
+
value: number
|
|
240
|
+
}) {
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-md">
|
|
243
|
+
{icon}
|
|
244
|
+
<div>
|
|
245
|
+
<div className="text-xs text-gray-500">{label}</div>
|
|
246
|
+
<div className="text-sm font-medium">{value}</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function DetailsList({
|
|
253
|
+
title,
|
|
254
|
+
items,
|
|
255
|
+
tone = 'neutral',
|
|
256
|
+
}: {
|
|
257
|
+
title: string
|
|
258
|
+
items: Array<{ id: string; text: string }>
|
|
259
|
+
tone?: 'neutral' | 'error'
|
|
260
|
+
}) {
|
|
261
|
+
const toneClasses =
|
|
262
|
+
tone === 'error'
|
|
263
|
+
? 'border-red-200 bg-red-50 text-red-800'
|
|
264
|
+
: 'border-gray-200 bg-gray-50 text-gray-800'
|
|
265
|
+
return (
|
|
266
|
+
<details className={`border rounded-md ${toneClasses}`}>
|
|
267
|
+
<summary className="px-3 py-2 text-xs font-medium cursor-pointer">
|
|
268
|
+
{title} ({items.length})
|
|
269
|
+
</summary>
|
|
270
|
+
<ul className="px-3 py-2 space-y-1 max-h-40 overflow-auto text-xs">
|
|
271
|
+
{items.map((item, i) => (
|
|
272
|
+
<li key={`${item.id}-${i}`} className="font-mono">
|
|
273
|
+
<span className="opacity-50">{item.id.slice(0, 8)}…</span>{' '}
|
|
274
|
+
<span className="font-sans">{item.text}</span>
|
|
275
|
+
</li>
|
|
276
|
+
))}
|
|
277
|
+
</ul>
|
|
278
|
+
</details>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import {
|
|
3
|
+
ApolloEnrichButton,
|
|
4
|
+
type ApolloEnrichmentClient,
|
|
5
|
+
type ApolloEnrichmentSummary,
|
|
6
|
+
} from '../ApolloEnrichButton'
|
|
7
|
+
|
|
8
|
+
function makeClient(
|
|
9
|
+
resolveWith: ApolloEnrichmentSummary | Promise<ApolloEnrichmentSummary> = {
|
|
10
|
+
total: 0,
|
|
11
|
+
enriched: [],
|
|
12
|
+
skipped: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
missing: [],
|
|
15
|
+
},
|
|
16
|
+
): ApolloEnrichmentClient & { enrichApollo: jest.Mock } {
|
|
17
|
+
const enrichApollo = jest
|
|
18
|
+
.fn()
|
|
19
|
+
.mockReturnValue(
|
|
20
|
+
resolveWith instanceof Promise ? resolveWith : Promise.resolve(resolveWith),
|
|
21
|
+
)
|
|
22
|
+
return { enrichApollo }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
describe('ApolloEnrichButton', () => {
|
|
27
|
+
it('renders the button label and contact count', () => {
|
|
28
|
+
const client = makeClient()
|
|
29
|
+
render(
|
|
30
|
+
<ApolloEnrichButton contactIds={['c-1', 'c-2', 'c-3']} enrichmentApi={client} />,
|
|
31
|
+
)
|
|
32
|
+
expect(screen.getByText(/Enrich with Apollo/i)).toBeInTheDocument()
|
|
33
|
+
expect(screen.getByText('(3)')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('disables the button when no contactIds', () => {
|
|
37
|
+
const client = makeClient()
|
|
38
|
+
render(<ApolloEnrichButton contactIds={[]} enrichmentApi={client} />)
|
|
39
|
+
const btn = screen.getByRole('button', { name: /Enrich with Apollo/i })
|
|
40
|
+
expect(btn).toBeDisabled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('disables the button when disabled=true', () => {
|
|
44
|
+
const client = makeClient()
|
|
45
|
+
render(
|
|
46
|
+
<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} disabled />,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByRole('button', { name: /Enrich with Apollo/i })).toBeDisabled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('opens dialog and calls enrichApollo with contactIds on click', async () => {
|
|
52
|
+
const summary: ApolloEnrichmentSummary = {
|
|
53
|
+
total: 2,
|
|
54
|
+
enriched: ['c-1', 'c-2'],
|
|
55
|
+
skipped: [],
|
|
56
|
+
errors: [],
|
|
57
|
+
missing: [],
|
|
58
|
+
}
|
|
59
|
+
const client = makeClient(summary)
|
|
60
|
+
render(
|
|
61
|
+
<ApolloEnrichButton contactIds={['c-1', 'c-2']} enrichmentApi={client} />,
|
|
62
|
+
)
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
64
|
+
|
|
65
|
+
expect(client.enrichApollo).toHaveBeenCalledWith(['c-1', 'c-2'])
|
|
66
|
+
|
|
67
|
+
await waitFor(() => {
|
|
68
|
+
expect(screen.getByText(/Apollo enrichment complete/i)).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows skipped reasons in the summary', async () => {
|
|
73
|
+
const summary: ApolloEnrichmentSummary = {
|
|
74
|
+
total: 1,
|
|
75
|
+
enriched: [],
|
|
76
|
+
skipped: [{ contact_id: 'c-1', reason: 'insufficient query fields' }],
|
|
77
|
+
errors: [],
|
|
78
|
+
missing: [],
|
|
79
|
+
}
|
|
80
|
+
const client = makeClient(summary)
|
|
81
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByText(/Skipped reasons/i)).toBeInTheDocument()
|
|
86
|
+
expect(screen.getByText(/insufficient query fields/i)).toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('shows errors in the summary', async () => {
|
|
91
|
+
const summary: ApolloEnrichmentSummary = {
|
|
92
|
+
total: 1,
|
|
93
|
+
enriched: [],
|
|
94
|
+
skipped: [],
|
|
95
|
+
errors: [{ contact_id: 'c-1', error: 'Apollo error: 401' }],
|
|
96
|
+
missing: [],
|
|
97
|
+
}
|
|
98
|
+
const client = makeClient(summary)
|
|
99
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
100
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByText(/Errored/i)).toBeInTheDocument()
|
|
104
|
+
expect(screen.getByText(/Apollo error: 401/i)).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows error alert when API call rejects', async () => {
|
|
109
|
+
const client = makeClient(Promise.reject(new Error('network down')))
|
|
110
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/network down/i)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('calls onComplete with the summary', async () => {
|
|
119
|
+
const summary: ApolloEnrichmentSummary = {
|
|
120
|
+
total: 1,
|
|
121
|
+
enriched: ['c-1'],
|
|
122
|
+
skipped: [],
|
|
123
|
+
errors: [],
|
|
124
|
+
missing: [],
|
|
125
|
+
}
|
|
126
|
+
const client = makeClient(summary)
|
|
127
|
+
const onComplete = jest.fn()
|
|
128
|
+
render(
|
|
129
|
+
<ApolloEnrichButton
|
|
130
|
+
contactIds={['c-1']}
|
|
131
|
+
enrichmentApi={client}
|
|
132
|
+
onComplete={onComplete}
|
|
133
|
+
/>,
|
|
134
|
+
)
|
|
135
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(onComplete).toHaveBeenCalledWith(summary)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('honors a custom label', () => {
|
|
143
|
+
const client = makeClient()
|
|
144
|
+
render(
|
|
145
|
+
<ApolloEnrichButton
|
|
146
|
+
contactIds={['c-1']}
|
|
147
|
+
enrichmentApi={client}
|
|
148
|
+
label="Custom Label"
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByRole('button', { name: /Custom Label/i })).toBeInTheDocument()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -6,3 +6,10 @@ export type { EnrichButtonProps } from './EnrichButton'
|
|
|
6
6
|
|
|
7
7
|
export { EnrichmentProgress } from './EnrichmentProgress'
|
|
8
8
|
export type { EnrichmentProgressProps, QueueStatus as EnrichmentQueueStatus } from './EnrichmentProgress'
|
|
9
|
+
|
|
10
|
+
export { ApolloEnrichButton } from './ApolloEnrichButton'
|
|
11
|
+
export type {
|
|
12
|
+
ApolloEnrichButtonProps,
|
|
13
|
+
ApolloEnrichmentClient,
|
|
14
|
+
ApolloEnrichmentSummary,
|
|
15
|
+
} from './ApolloEnrichButton'
|