@startsimpli/funnels 0.4.14 → 0.4.16
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 +11 -3
- package/src/api/client.paths.test.ts +20 -7
- package/src/api/client.ts +6 -0
- package/src/api/http-adapter.test.ts +68 -0
- package/src/api/http-adapter.ts +48 -0
- package/src/api/index.ts +5 -0
- package/src/api/paths.test.ts +18 -5
- package/src/api/paths.ts +52 -6
- package/src/page/FunnelsPage.test.tsx +383 -0
- package/src/page/FunnelsPage.tsx +739 -0
- package/src/page/index.ts +24 -0
- package/src/page/types.ts +102 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FUNNELS — the whole screen, once, for every surface that has funnels.
|
|
5
|
+
*
|
|
6
|
+
* bd startsim-em5mn, under CLAUDE.md rule 9 and the share-page-composers
|
|
7
|
+
* convention: foundry-web, the tenant template and market-web mount THIS
|
|
8
|
+
* rather than each rebuilding a list, a builder, a run history and a results
|
|
9
|
+
* table. The precedent is the tenant template's planning page mounting
|
|
10
|
+
* `PlanningPage` from @startsimpli/integrations — the app file's whole share is
|
|
11
|
+
* a heading and the mount.
|
|
12
|
+
*
|
|
13
|
+
* WHY IT LIVES IN @startsimpli/funnels AND NOT @startsimpli/ui.
|
|
14
|
+
* `packages/api/src/types/funnel.ts` records the reason in its own words: the
|
|
15
|
+
* funnel types are duplicated into @startsimpli/api rather than imported
|
|
16
|
+
* "because every app installs @startsimpli/api and only two install
|
|
17
|
+
* @startsimpli/funnels (which drags @xyflow/react and @dnd-kit with it)". A
|
|
18
|
+
* composer in @startsimpli/ui would invert exactly that — the library every app
|
|
19
|
+
* installs would depend on the heavy domain package. Here, the dependency runs
|
|
20
|
+
* the harmless way: funnels takes ONE table from ui, as an OPTIONAL PEER, and
|
|
21
|
+
* only on this subpath. Hence `@startsimpli/funnels/page` rather than the root
|
|
22
|
+
* barrel — the same reasoning `packages/ui/src/foundry/index.ts` writes down
|
|
23
|
+
* for its own subpath ("A SUBPATH AND NOT THE ROOT BARREL, deliberately"), so
|
|
24
|
+
* raise and market keep importing @startsimpli/funnels without pulling ui in.
|
|
25
|
+
*
|
|
26
|
+
* EVERY LIST IS PAGED BY THE SERVER (repo rule #8). The funnel list and the
|
|
27
|
+
* results list each hold one page and ask for the next; neither ever holds the
|
|
28
|
+
* corpus. There is NO client-side sort: the control-plane list is ordered
|
|
29
|
+
* `("-created_at", "-id")` by `service_api.list_funnels` and reads no
|
|
30
|
+
* `ordering` param, so offering a sortable header would be a control that
|
|
31
|
+
* lies. bd startsim-em5mn.3 tracks the server-side ordering.
|
|
32
|
+
*
|
|
33
|
+
* THERE IS NO PREVIEW, DELIBERATELY. `core/engine.ts` evaluates an in-memory
|
|
34
|
+
* array and the corpus is ~208k public rows, so an in-browser preview is out on
|
|
35
|
+
* its face. The server-side alternative is worse than absent here: no `preview`
|
|
36
|
+
* action is mounted on FoundryViewSet at all (verified against prod
|
|
37
|
+
* 2026-09-18), and the client's server-side preview call would make the CALLER
|
|
38
|
+
* post the entities — the same paging-into-the-browser by another name. THE RUN IS THE
|
|
39
|
+
* PREVIEW: `POST funnels/<id>/runs/` accepts an `entity_ids` subset, and the
|
|
40
|
+
* run's `stage_breakdown` is the per-stage funnel chart, computed server-side.
|
|
41
|
+
* bd startsim-em5mn.2 tracks a sampled-preview route.
|
|
42
|
+
*/
|
|
43
|
+
import * as React from 'react';
|
|
44
|
+
import { UnifiedTable } from '@startsimpli/ui/table';
|
|
45
|
+
|
|
46
|
+
import { FunnelStageBuilder } from '../components/FunnelStageBuilder';
|
|
47
|
+
import { FunnelRunHistory } from '../components/FunnelRunHistory';
|
|
48
|
+
import type { FunnelApiClient } from '../api/client';
|
|
49
|
+
import type {
|
|
50
|
+
FieldDefinition,
|
|
51
|
+
Funnel,
|
|
52
|
+
FunnelRun,
|
|
53
|
+
FunnelStage,
|
|
54
|
+
FunnelStatus,
|
|
55
|
+
} from '../types';
|
|
56
|
+
import type {
|
|
57
|
+
FunnelListRow,
|
|
58
|
+
FunnelResultRow,
|
|
59
|
+
FunnelsPageProps,
|
|
60
|
+
} from './types';
|
|
61
|
+
|
|
62
|
+
const STATUSES: FunnelStatus[] = ['draft', 'active', 'paused', 'archived'];
|
|
63
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
64
|
+
|
|
65
|
+
/** The literal the results filter uses for "was never excluded". */
|
|
66
|
+
const STAGE_REACHED_END = 'end';
|
|
67
|
+
|
|
68
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stages, with ids the server will accept.
|
|
72
|
+
*
|
|
73
|
+
* `FunnelStageBuilder` mints an id for a stage the moment it is added —
|
|
74
|
+
* `stage-<timestamp>-<random>` — because its list needs a stable React key
|
|
75
|
+
* before anything is saved. `FunnelStageSerializer.id` is a `UUIDField`, so
|
|
76
|
+
* sending that back is a 400: {'id': ['Must be a valid UUID.']}. Verified
|
|
77
|
+
* against a live control plane on 2026-09-18 — the same PATCH without the id
|
|
78
|
+
* is a 200.
|
|
79
|
+
*
|
|
80
|
+
* So a client-minted id is dropped and a server-assigned one is kept, which is
|
|
81
|
+
* exactly the distinction the reconciler needs: it updates the stage a client
|
|
82
|
+
* NAMES and creates one it does not, and keeping the real ids is what stops an
|
|
83
|
+
* edit from reissuing every stage id underneath the funnel — `stage_stats` is
|
|
84
|
+
* keyed on stage id and results record `reached_stage_id` (bd startsim-0wmf8.1).
|
|
85
|
+
*/
|
|
86
|
+
function stagesForSave(stages: FunnelStage[] = []): FunnelStage[] {
|
|
87
|
+
return stages.map((stage) => {
|
|
88
|
+
if (stage.id && UUID.test(stage.id)) return stage;
|
|
89
|
+
const { id: _minted, ...rest } = stage;
|
|
90
|
+
return rest as FunnelStage;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function when(value?: string | Date | null): string {
|
|
95
|
+
if (!value) return '—';
|
|
96
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
97
|
+
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function count(value?: number | null): string {
|
|
101
|
+
return typeof value === 'number' ? value.toLocaleString() : '—';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One request in flight at a time, latest wins.
|
|
106
|
+
*
|
|
107
|
+
* Typing in the search box fires a request per keystroke and they do not come
|
|
108
|
+
* back in order; without the guard a slow early response overwrites a fast late
|
|
109
|
+
* one and the table shows results for a query the box no longer holds.
|
|
110
|
+
*/
|
|
111
|
+
function useLatest<T>(
|
|
112
|
+
load: () => Promise<T>,
|
|
113
|
+
deps: React.DependencyList,
|
|
114
|
+
): { data: T | null; error: string | null; loading: boolean } {
|
|
115
|
+
const [data, setData] = React.useState<T | null>(null);
|
|
116
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
117
|
+
const [loading, setLoading] = React.useState(true);
|
|
118
|
+
const seq = React.useRef(0);
|
|
119
|
+
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
const ticket = ++seq.current;
|
|
122
|
+
let live = true;
|
|
123
|
+
setLoading(true);
|
|
124
|
+
load()
|
|
125
|
+
.then((result) => {
|
|
126
|
+
if (!live || ticket !== seq.current) return;
|
|
127
|
+
setData(result);
|
|
128
|
+
setError(null);
|
|
129
|
+
})
|
|
130
|
+
.catch((e: unknown) => {
|
|
131
|
+
if (!live || ticket !== seq.current) return;
|
|
132
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
133
|
+
})
|
|
134
|
+
.finally(() => {
|
|
135
|
+
if (live && ticket === seq.current) setLoading(false);
|
|
136
|
+
});
|
|
137
|
+
return () => {
|
|
138
|
+
live = false;
|
|
139
|
+
};
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
}, [...deps]);
|
|
142
|
+
|
|
143
|
+
return { data, error, loading };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The last non-empty value seen.
|
|
148
|
+
*
|
|
149
|
+
* The rule editor's field picker is a CONTROLLED select over the vocabulary.
|
|
150
|
+
* While a refetch is in flight `useLatest` still holds the previous payload,
|
|
151
|
+
* but on the first load — and on a genuine change of entity type — the list is
|
|
152
|
+
* briefly empty, and a controlled select whose options do not contain its value
|
|
153
|
+
* renders blank. The rule's stored `field` survives that (no change event
|
|
154
|
+
* fires), but the author watches their field unselect itself, which is worse
|
|
155
|
+
* than a stale list for the half-second it takes.
|
|
156
|
+
*/
|
|
157
|
+
function useSticky<T>(value: T[]): T[] {
|
|
158
|
+
const held = React.useRef<T[]>(value);
|
|
159
|
+
if (value.length > 0) held.current = value;
|
|
160
|
+
return value.length > 0 ? value : held.current;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const label = 'block text-xs font-medium text-muted-foreground';
|
|
164
|
+
const control =
|
|
165
|
+
'h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground';
|
|
166
|
+
const button =
|
|
167
|
+
'inline-flex h-9 items-center rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:opacity-50';
|
|
168
|
+
const primary =
|
|
169
|
+
'inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50';
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// The list
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function FunnelList({
|
|
176
|
+
client,
|
|
177
|
+
canManage,
|
|
178
|
+
pageSize,
|
|
179
|
+
onOpen,
|
|
180
|
+
}: {
|
|
181
|
+
client: FunnelApiClient;
|
|
182
|
+
canManage: boolean;
|
|
183
|
+
pageSize: number;
|
|
184
|
+
onOpen: (funnel: FunnelListRow) => void;
|
|
185
|
+
}) {
|
|
186
|
+
const [status, setStatus] = React.useState<FunnelStatus | ''>('');
|
|
187
|
+
const [search, setSearch] = React.useState('');
|
|
188
|
+
const [page, setPage] = React.useState(1);
|
|
189
|
+
const [creating, setCreating] = React.useState(false);
|
|
190
|
+
const [name, setName] = React.useState('');
|
|
191
|
+
const [entityType, setEntityType] = React.useState('');
|
|
192
|
+
const [saving, setSaving] = React.useState(false);
|
|
193
|
+
|
|
194
|
+
// Every narrowing is a QUERY PARAM. `status`/`search` are what
|
|
195
|
+
// `service_api.list_funnels` reads; nothing here filters rows it already
|
|
196
|
+
// holds, because it only ever holds one page of them.
|
|
197
|
+
const listing = useLatest(
|
|
198
|
+
() =>
|
|
199
|
+
client.listFunnels<unknown>({
|
|
200
|
+
page,
|
|
201
|
+
pageSize,
|
|
202
|
+
...(status ? { status } : {}),
|
|
203
|
+
...(search ? { search } : {}),
|
|
204
|
+
}),
|
|
205
|
+
[client, page, pageSize, status, search],
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const narrow = (apply: () => void) => {
|
|
209
|
+
apply();
|
|
210
|
+
setPage(1); // a narrower set has a different page 1
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const rows = (listing.data?.results ?? []) as FunnelListRow[];
|
|
214
|
+
|
|
215
|
+
const create = async (e: React.FormEvent) => {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
if (!name.trim()) return;
|
|
218
|
+
setSaving(true);
|
|
219
|
+
try {
|
|
220
|
+
const made = await client.createFunnel({
|
|
221
|
+
name: name.trim(),
|
|
222
|
+
status: 'draft',
|
|
223
|
+
entityType: entityType.trim() || 'contact',
|
|
224
|
+
stages: [],
|
|
225
|
+
});
|
|
226
|
+
setCreating(false);
|
|
227
|
+
setName('');
|
|
228
|
+
setEntityType('');
|
|
229
|
+
onOpen(made as FunnelListRow);
|
|
230
|
+
} finally {
|
|
231
|
+
setSaving(false);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className="space-y-4" data-testid="funnels-list">
|
|
237
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
238
|
+
<div>
|
|
239
|
+
<label className={label} htmlFor="funnels-search">
|
|
240
|
+
Search
|
|
241
|
+
</label>
|
|
242
|
+
<input
|
|
243
|
+
id="funnels-search"
|
|
244
|
+
className={`${control} w-56`}
|
|
245
|
+
placeholder="Search funnels by name"
|
|
246
|
+
value={search}
|
|
247
|
+
onChange={(e) => narrow(() => setSearch(e.target.value))}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<label className={label} htmlFor="funnels-status">
|
|
252
|
+
Status
|
|
253
|
+
</label>
|
|
254
|
+
<select
|
|
255
|
+
id="funnels-status"
|
|
256
|
+
className={control}
|
|
257
|
+
value={status}
|
|
258
|
+
onChange={(e) => narrow(() => setStatus(e.target.value as FunnelStatus | ''))}
|
|
259
|
+
>
|
|
260
|
+
<option value="">Any status</option>
|
|
261
|
+
{STATUSES.map((s) => (
|
|
262
|
+
<option key={s} value={s}>
|
|
263
|
+
{s}
|
|
264
|
+
</option>
|
|
265
|
+
))}
|
|
266
|
+
</select>
|
|
267
|
+
</div>
|
|
268
|
+
<span className="flex-1" />
|
|
269
|
+
{canManage && (
|
|
270
|
+
<button type="button" className={primary} onClick={() => setCreating((v) => !v)}>
|
|
271
|
+
New funnel
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{creating && canManage && (
|
|
277
|
+
<form className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3" onSubmit={create}>
|
|
278
|
+
<div>
|
|
279
|
+
<label className={label} htmlFor="new-funnel-name">
|
|
280
|
+
Name
|
|
281
|
+
</label>
|
|
282
|
+
<input
|
|
283
|
+
id="new-funnel-name"
|
|
284
|
+
className={`${control} w-56`}
|
|
285
|
+
value={name}
|
|
286
|
+
onChange={(e) => setName(e.target.value)}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
<div>
|
|
290
|
+
<label className={label} htmlFor="new-funnel-entity-type">
|
|
291
|
+
Entity type
|
|
292
|
+
</label>
|
|
293
|
+
<input
|
|
294
|
+
id="new-funnel-entity-type"
|
|
295
|
+
className={control}
|
|
296
|
+
placeholder="contact"
|
|
297
|
+
value={entityType}
|
|
298
|
+
onChange={(e) => setEntityType(e.target.value)}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
<button type="submit" className={primary} disabled={!name.trim() || saving}>
|
|
302
|
+
{saving ? 'Creating…' : 'Create'}
|
|
303
|
+
</button>
|
|
304
|
+
</form>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{listing.error && (
|
|
308
|
+
<p className="text-sm text-red-600" role="alert">
|
|
309
|
+
Couldn’t load funnels: {listing.error}
|
|
310
|
+
</p>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
<UnifiedTable<FunnelListRow>
|
|
314
|
+
tableId="funnels"
|
|
315
|
+
data={rows}
|
|
316
|
+
getRowId={(row) => row.id}
|
|
317
|
+
loading={listing.loading}
|
|
318
|
+
onRowClick={onOpen}
|
|
319
|
+
emptyState={
|
|
320
|
+
<p className="p-6 text-center text-sm text-muted-foreground">
|
|
321
|
+
No funnels yet. A funnel is a set of stages this foundry runs over its own
|
|
322
|
+
entities.
|
|
323
|
+
</p>
|
|
324
|
+
}
|
|
325
|
+
pagination={{
|
|
326
|
+
enabled: true,
|
|
327
|
+
serverSide: true,
|
|
328
|
+
pageSize,
|
|
329
|
+
currentPage: page,
|
|
330
|
+
totalCount: listing.data?.count ?? 0,
|
|
331
|
+
onPageChange: setPage,
|
|
332
|
+
}}
|
|
333
|
+
columns={[
|
|
334
|
+
{ id: 'name', header: 'Name', accessorKey: 'name', sortable: false },
|
|
335
|
+
{ id: 'status', header: 'Status', accessorKey: 'status', sortable: false },
|
|
336
|
+
{ id: 'entityType', header: 'Entity type', sortable: false, cell: (r) => r.entityType || '—' },
|
|
337
|
+
{ id: 'stages', header: 'Stages', sortable: false, cell: (r) => count(r.stageCount ?? r.stages?.length) },
|
|
338
|
+
{ id: 'runs', header: 'Runs', sortable: false, cell: (r) => count(r.totalRuns) },
|
|
339
|
+
{ id: 'matched', header: 'Last matched', sortable: false, cell: (r) => count(r.lastRunMatched) },
|
|
340
|
+
{ id: 'lastRun', header: 'Last run', sortable: false, cell: (r) => when(r.lastRunAt) },
|
|
341
|
+
]}
|
|
342
|
+
/>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// The results of one run
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function RunResults({
|
|
352
|
+
client,
|
|
353
|
+
funnel,
|
|
354
|
+
runId,
|
|
355
|
+
pageSize,
|
|
356
|
+
}: {
|
|
357
|
+
client: FunnelApiClient;
|
|
358
|
+
funnel: Funnel;
|
|
359
|
+
runId: string;
|
|
360
|
+
pageSize: number;
|
|
361
|
+
}) {
|
|
362
|
+
const [stage, setStage] = React.useState('');
|
|
363
|
+
const [outcome, setOutcome] = React.useState('');
|
|
364
|
+
const [page, setPage] = React.useState(1);
|
|
365
|
+
|
|
366
|
+
// `stage` and `matched` are SERVER filters (`service_api.list_results`):
|
|
367
|
+
// a stage id means "stopped here", 'end' means "got all the way through".
|
|
368
|
+
// Filtering here instead would mean holding every result in the browser.
|
|
369
|
+
const listing = useLatest(
|
|
370
|
+
() =>
|
|
371
|
+
client.getFunnelResults<unknown>(runId, {
|
|
372
|
+
page,
|
|
373
|
+
pageSize,
|
|
374
|
+
funnelId: funnel.id,
|
|
375
|
+
...(stage ? { stage } : {}),
|
|
376
|
+
...(outcome ? { matched: outcome === 'true' } : {}),
|
|
377
|
+
}),
|
|
378
|
+
[client, runId, funnel.id, page, pageSize, stage, outcome],
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const narrow = (apply: () => void) => {
|
|
382
|
+
apply();
|
|
383
|
+
setPage(1);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const rows = (listing.data?.results ?? []) as unknown as FunnelResultRow[];
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div className="space-y-4" data-testid="funnel-results">
|
|
390
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
391
|
+
<div>
|
|
392
|
+
<label className={label} htmlFor="results-stage">
|
|
393
|
+
Stage reached
|
|
394
|
+
</label>
|
|
395
|
+
<select
|
|
396
|
+
id="results-stage"
|
|
397
|
+
className={control}
|
|
398
|
+
value={stage}
|
|
399
|
+
onChange={(e) => narrow(() => setStage(e.target.value))}
|
|
400
|
+
>
|
|
401
|
+
<option value="">Any stage</option>
|
|
402
|
+
{[...(funnel.stages ?? [])]
|
|
403
|
+
.sort((a, b) => a.order - b.order)
|
|
404
|
+
.map((s: FunnelStage) => (
|
|
405
|
+
<option key={s.id} value={s.id}>
|
|
406
|
+
Stopped at {s.name}
|
|
407
|
+
</option>
|
|
408
|
+
))}
|
|
409
|
+
<option value={STAGE_REACHED_END}>Reached the end</option>
|
|
410
|
+
</select>
|
|
411
|
+
</div>
|
|
412
|
+
<div>
|
|
413
|
+
<label className={label} htmlFor="results-outcome">
|
|
414
|
+
Outcome
|
|
415
|
+
</label>
|
|
416
|
+
<select
|
|
417
|
+
id="results-outcome"
|
|
418
|
+
className={control}
|
|
419
|
+
value={outcome}
|
|
420
|
+
onChange={(e) => narrow(() => setOutcome(e.target.value))}
|
|
421
|
+
>
|
|
422
|
+
<option value="">Any outcome</option>
|
|
423
|
+
<option value="true">Matched</option>
|
|
424
|
+
<option value="false">Excluded</option>
|
|
425
|
+
</select>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
429
|
+
{listing.error && (
|
|
430
|
+
<p className="text-sm text-red-600" role="alert">
|
|
431
|
+
Couldn’t load results: {listing.error}
|
|
432
|
+
</p>
|
|
433
|
+
)}
|
|
434
|
+
|
|
435
|
+
<UnifiedTable<FunnelResultRow>
|
|
436
|
+
tableId="funnel-results"
|
|
437
|
+
data={rows}
|
|
438
|
+
getRowId={(row) => row.id}
|
|
439
|
+
loading={listing.loading}
|
|
440
|
+
emptyState={
|
|
441
|
+
<p className="p-6 text-center text-sm text-muted-foreground">
|
|
442
|
+
This run produced no rows under that filter.
|
|
443
|
+
</p>
|
|
444
|
+
}
|
|
445
|
+
pagination={{
|
|
446
|
+
enabled: true,
|
|
447
|
+
serverSide: true,
|
|
448
|
+
pageSize,
|
|
449
|
+
currentPage: page,
|
|
450
|
+
totalCount: listing.data?.count ?? 0,
|
|
451
|
+
onPageChange: setPage,
|
|
452
|
+
}}
|
|
453
|
+
columns={[
|
|
454
|
+
{ id: 'entityId', header: 'Entity', sortable: false, cell: (r) => r.entityId ?? '—' },
|
|
455
|
+
{ id: 'entityType', header: 'Type', sortable: false, cell: (r) => r.entityType ?? '—' },
|
|
456
|
+
{
|
|
457
|
+
id: 'matched',
|
|
458
|
+
header: 'Outcome',
|
|
459
|
+
sortable: false,
|
|
460
|
+
cell: (r) => (r.matched ? 'Matched' : 'Excluded'),
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
id: 'stageReached',
|
|
464
|
+
header: 'Stage reached',
|
|
465
|
+
sortable: false,
|
|
466
|
+
// The name the RUN recorded, not the stage's current name — a run
|
|
467
|
+
// that ran against "Angels" keeps saying "Angels" (breakdown.py).
|
|
468
|
+
cell: (r) => r.stageReached?.name ?? (r.matched ? 'Reached the end' : '—'),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: 'tags',
|
|
472
|
+
header: 'Tags',
|
|
473
|
+
sortable: false,
|
|
474
|
+
cell: (r) => (r.accumulatedTags?.length ? r.accumulatedTags.join(', ') : '—'),
|
|
475
|
+
},
|
|
476
|
+
{ id: 'createdAt', header: 'When', sortable: false, cell: (r) => when(r.createdAt) },
|
|
477
|
+
]}
|
|
478
|
+
/>
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// One funnel
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
type Tab = 'stages' | 'runs' | 'results';
|
|
488
|
+
|
|
489
|
+
function FunnelDetail({
|
|
490
|
+
client,
|
|
491
|
+
fieldsClient,
|
|
492
|
+
funnelId,
|
|
493
|
+
canManage,
|
|
494
|
+
pageSize,
|
|
495
|
+
onBack,
|
|
496
|
+
}: {
|
|
497
|
+
client: FunnelApiClient;
|
|
498
|
+
fieldsClient: FunnelApiClient;
|
|
499
|
+
funnelId: string;
|
|
500
|
+
canManage: boolean;
|
|
501
|
+
pageSize: number;
|
|
502
|
+
onBack: () => void;
|
|
503
|
+
}) {
|
|
504
|
+
const [tab, setTab] = React.useState<Tab>('stages');
|
|
505
|
+
const [draft, setDraft] = React.useState<Funnel | null>(null);
|
|
506
|
+
const [activeRun, setActiveRun] = React.useState<FunnelRun | null>(null);
|
|
507
|
+
const [resultsRun, setResultsRun] = React.useState<string | null>(null);
|
|
508
|
+
const [busy, setBusy] = React.useState<string | null>(null);
|
|
509
|
+
const [problem, setProblem] = React.useState<string | null>(null);
|
|
510
|
+
|
|
511
|
+
const loaded = useLatest(() => client.getFunnel(funnelId), [client, funnelId]);
|
|
512
|
+
|
|
513
|
+
// The vocabulary comes from the INJECTED fields client. It is a separate
|
|
514
|
+
// client because `/fields/` is only mounted on central — the control plane's
|
|
515
|
+
// `funnels/fields/` is shadowed by its `funnels/<id>/` action and answers
|
|
516
|
+
// "funnel not found". Never a hand-written list: the same registry validates
|
|
517
|
+
// the rule on POST, so a picker built from anything else offers fields that
|
|
518
|
+
// 400 on save.
|
|
519
|
+
const vocabulary = useLatest(
|
|
520
|
+
() => fieldsClient.getFields(draft?.entityType || undefined),
|
|
521
|
+
[fieldsClient, draft?.entityType],
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Sticky, so a refetch on a changed entity type does not blank the field
|
|
525
|
+
// picker under an author who is mid-rule.
|
|
526
|
+
const fieldRegistry = useSticky(
|
|
527
|
+
(vocabulary.data?.fields ?? []) as FieldDefinition[],
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
React.useEffect(() => {
|
|
531
|
+
if (loaded.data) {
|
|
532
|
+
setDraft(loaded.data as Funnel);
|
|
533
|
+
const last = (loaded.data as FunnelListRow).lastRunId;
|
|
534
|
+
setResultsRun((current) => current ?? last ?? null);
|
|
535
|
+
}
|
|
536
|
+
}, [loaded.data]);
|
|
537
|
+
|
|
538
|
+
const runNow = async () => {
|
|
539
|
+
setBusy('run');
|
|
540
|
+
setProblem(null);
|
|
541
|
+
try {
|
|
542
|
+
// The id this POST returns is the id that completes — the run row is
|
|
543
|
+
// created empty and adopted by the task (bd startsim-qv6pk). So the page
|
|
544
|
+
// follows THIS id rather than guessing at the newest run afterwards.
|
|
545
|
+
const run = await client.runFunnel(funnelId, { triggerType: 'manual' });
|
|
546
|
+
setActiveRun(run);
|
|
547
|
+
setResultsRun(run.id);
|
|
548
|
+
setTab('runs');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
setProblem(e instanceof Error ? e.message : String(e));
|
|
551
|
+
} finally {
|
|
552
|
+
setBusy(null);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const save = async () => {
|
|
557
|
+
if (!draft) return;
|
|
558
|
+
setBusy('save');
|
|
559
|
+
setProblem(null);
|
|
560
|
+
try {
|
|
561
|
+
const saved = await client.updateFunnel(funnelId, {
|
|
562
|
+
name: draft.name,
|
|
563
|
+
status: draft.status,
|
|
564
|
+
entityType: draft.entityType,
|
|
565
|
+
stages: stagesForSave(draft.stages),
|
|
566
|
+
});
|
|
567
|
+
setDraft(saved as Funnel);
|
|
568
|
+
} catch (e) {
|
|
569
|
+
setProblem(e instanceof Error ? e.message : String(e));
|
|
570
|
+
} finally {
|
|
571
|
+
setBusy(null);
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
if (loaded.error) {
|
|
576
|
+
return (
|
|
577
|
+
<div className="space-y-3">
|
|
578
|
+
<button type="button" className={button} onClick={onBack}>
|
|
579
|
+
← All funnels
|
|
580
|
+
</button>
|
|
581
|
+
<p className="text-sm text-red-600" role="alert">
|
|
582
|
+
Couldn’t load this funnel: {loaded.error}
|
|
583
|
+
</p>
|
|
584
|
+
</div>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!draft) {
|
|
589
|
+
return <p className="text-sm text-muted-foreground">Loading funnel…</p>;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const tabs: Array<[Tab, string]> = [
|
|
593
|
+
['stages', 'Stages'],
|
|
594
|
+
['runs', 'Runs'],
|
|
595
|
+
['results', 'Results'],
|
|
596
|
+
];
|
|
597
|
+
|
|
598
|
+
return (
|
|
599
|
+
<div className="space-y-5" data-testid="funnel-detail">
|
|
600
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
601
|
+
<button type="button" className={button} onClick={onBack}>
|
|
602
|
+
← All funnels
|
|
603
|
+
</button>
|
|
604
|
+
<input
|
|
605
|
+
className={`${control} w-64 font-medium`}
|
|
606
|
+
aria-label="Funnel name"
|
|
607
|
+
value={draft.name}
|
|
608
|
+
disabled={!canManage}
|
|
609
|
+
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
|
610
|
+
/>
|
|
611
|
+
<select
|
|
612
|
+
className={control}
|
|
613
|
+
aria-label="Funnel status"
|
|
614
|
+
value={draft.status}
|
|
615
|
+
disabled={!canManage}
|
|
616
|
+
onChange={(e) => setDraft({ ...draft, status: e.target.value as FunnelStatus })}
|
|
617
|
+
>
|
|
618
|
+
{STATUSES.map((s) => (
|
|
619
|
+
<option key={s} value={s}>
|
|
620
|
+
{s}
|
|
621
|
+
</option>
|
|
622
|
+
))}
|
|
623
|
+
</select>
|
|
624
|
+
<span className="flex-1" />
|
|
625
|
+
{canManage && (
|
|
626
|
+
<>
|
|
627
|
+
<button type="button" className={button} onClick={save} disabled={busy === 'save'}>
|
|
628
|
+
{busy === 'save' ? 'Saving…' : 'Save'}
|
|
629
|
+
</button>
|
|
630
|
+
<button type="button" className={primary} onClick={runNow} disabled={busy === 'run'}>
|
|
631
|
+
{busy === 'run' ? 'Starting…' : 'Run now'}
|
|
632
|
+
</button>
|
|
633
|
+
</>
|
|
634
|
+
)}
|
|
635
|
+
</div>
|
|
636
|
+
|
|
637
|
+
{activeRun && (
|
|
638
|
+
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
|
639
|
+
Started run <span data-testid="active-run">{activeRun.id}</span> — it is{' '}
|
|
640
|
+
{activeRun.status}. The Runs tab follows it to completion.
|
|
641
|
+
</p>
|
|
642
|
+
)}
|
|
643
|
+
{problem && (
|
|
644
|
+
<p className="text-sm text-red-600" role="alert">
|
|
645
|
+
{problem}
|
|
646
|
+
</p>
|
|
647
|
+
)}
|
|
648
|
+
|
|
649
|
+
<div role="tablist" className="flex gap-1 border-b border-border">
|
|
650
|
+
{tabs.map(([key, text]) => (
|
|
651
|
+
<button
|
|
652
|
+
key={key}
|
|
653
|
+
type="button"
|
|
654
|
+
role="tab"
|
|
655
|
+
aria-selected={tab === key}
|
|
656
|
+
className={`h-9 px-3 text-sm ${tab === key ? 'border-b-2 border-primary font-medium' : 'text-muted-foreground'}`}
|
|
657
|
+
onClick={() => setTab(key)}
|
|
658
|
+
>
|
|
659
|
+
{text}
|
|
660
|
+
</button>
|
|
661
|
+
))}
|
|
662
|
+
</div>
|
|
663
|
+
|
|
664
|
+
{tab === 'stages' && (
|
|
665
|
+
<div data-testid="funnel-stages">
|
|
666
|
+
{vocabulary.error && (
|
|
667
|
+
<p className="mb-3 text-sm text-red-600" role="alert">
|
|
668
|
+
Couldn’t load the field vocabulary: {vocabulary.error}. Rules can’t be
|
|
669
|
+
edited until it loads — the same registry validates them on save.
|
|
670
|
+
</p>
|
|
671
|
+
)}
|
|
672
|
+
<FunnelStageBuilder
|
|
673
|
+
funnel={draft}
|
|
674
|
+
onUpdate={setDraft}
|
|
675
|
+
fieldRegistry={fieldRegistry}
|
|
676
|
+
/>
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
679
|
+
|
|
680
|
+
{tab === 'runs' && (
|
|
681
|
+
<FunnelRunHistory
|
|
682
|
+
funnelId={funnelId}
|
|
683
|
+
apiClient={client}
|
|
684
|
+
onViewResults={(run) => {
|
|
685
|
+
setResultsRun(run.id);
|
|
686
|
+
setTab('results');
|
|
687
|
+
}}
|
|
688
|
+
/>
|
|
689
|
+
)}
|
|
690
|
+
|
|
691
|
+
{tab === 'results' &&
|
|
692
|
+
(resultsRun ? (
|
|
693
|
+
<RunResults client={client} funnel={draft} runId={resultsRun} pageSize={pageSize} />
|
|
694
|
+
) : (
|
|
695
|
+
<p className="text-sm text-muted-foreground">
|
|
696
|
+
No run to show results for yet. Hit “Run now”, or pick a run from the Runs
|
|
697
|
+
tab.
|
|
698
|
+
</p>
|
|
699
|
+
))}
|
|
700
|
+
</div>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
// The page
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
export function FunnelsPage({
|
|
709
|
+
client,
|
|
710
|
+
fieldsClient,
|
|
711
|
+
canManage = false,
|
|
712
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
713
|
+
className = '',
|
|
714
|
+
}: FunnelsPageProps) {
|
|
715
|
+
const [selected, setSelected] = React.useState<string | null>(null);
|
|
716
|
+
const fields = fieldsClient ?? client;
|
|
717
|
+
|
|
718
|
+
return (
|
|
719
|
+
<div className={`space-y-5 ${className}`} data-testid="funnels-page">
|
|
720
|
+
{selected ? (
|
|
721
|
+
<FunnelDetail
|
|
722
|
+
client={client}
|
|
723
|
+
fieldsClient={fields}
|
|
724
|
+
funnelId={selected}
|
|
725
|
+
canManage={canManage}
|
|
726
|
+
pageSize={pageSize}
|
|
727
|
+
onBack={() => setSelected(null)}
|
|
728
|
+
/>
|
|
729
|
+
) : (
|
|
730
|
+
<FunnelList
|
|
731
|
+
client={client}
|
|
732
|
+
canManage={canManage}
|
|
733
|
+
pageSize={pageSize}
|
|
734
|
+
onOpen={(row) => setSelected(row.id)}
|
|
735
|
+
/>
|
|
736
|
+
)}
|
|
737
|
+
</div>
|
|
738
|
+
);
|
|
739
|
+
}
|