@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.9",
3
+ "version": "0.4.13",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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'