@aglyn/plugins-outreach 1.0.0-beta.199 → 1.0.0-beta.201

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.
Files changed (108) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/do-not-contact-domains.js +60 -42
  3. package/src/lib/components/do-not-contact-domains.js.map +1 -1
  4. package/src/lib/components/enrollment-actions.d.ts +33 -0
  5. package/src/lib/components/enrollment-actions.js +174 -0
  6. package/src/lib/components/enrollment-actions.js.map +1 -0
  7. package/src/lib/components/enrollment-detail.d.ts +21 -0
  8. package/src/lib/components/enrollment-detail.js +870 -0
  9. package/src/lib/components/enrollment-detail.js.map +1 -0
  10. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  11. package/src/lib/components/enrollment-filter-params.js +98 -0
  12. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  13. package/src/lib/components/enrollments-table.d.ts +25 -2
  14. package/src/lib/components/enrollments-table.js +303 -283
  15. package/src/lib/components/enrollments-table.js.map +1 -1
  16. package/src/lib/components/outreach-console-page.js +10 -0
  17. package/src/lib/components/outreach-console-page.js.map +1 -1
  18. package/src/lib/components/sequence-detail.d.ts +2 -0
  19. package/src/lib/components/sequence-detail.js +73 -11
  20. package/src/lib/components/sequence-detail.js.map +1 -1
  21. package/src/lib/components/sequence-report-card.d.ts +17 -0
  22. package/src/lib/components/sequence-report-card.js +63 -15
  23. package/src/lib/components/sequence-report-card.js.map +1 -1
  24. package/src/lib/components/sequences-section.d.ts +13 -1
  25. package/src/lib/components/sequences-section.js +108 -83
  26. package/src/lib/components/sequences-section.js.map +1 -1
  27. package/src/lib/components/use-outreach-api.d.ts +3 -1
  28. package/src/lib/components/use-outreach-api.js +6 -0
  29. package/src/lib/components/use-outreach-api.js.map +1 -1
  30. package/src/lib/components/use-outreach-data.d.ts +30 -25
  31. package/src/lib/components/use-outreach-data.js +93 -104
  32. package/src/lib/components/use-outreach-data.js.map +1 -1
  33. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  34. package/src/lib/components/use-outreach-enrollment.js +133 -0
  35. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  36. package/src/lib/constants/api-routes.d.ts +2 -0
  37. package/src/lib/constants/api-routes.js +1 -0
  38. package/src/lib/constants/api-routes.js.map +1 -1
  39. package/src/lib/engine/gates.d.ts +4 -3
  40. package/src/lib/engine/gates.js.map +1 -1
  41. package/src/lib/engine/gateway-block.d.ts +4 -8
  42. package/src/lib/engine/gateway-block.js +6 -26
  43. package/src/lib/engine/gateway-block.js.map +1 -1
  44. package/src/lib/engine/mail-gateway.d.ts +18 -73
  45. package/src/lib/engine/mail-gateway.js +32 -206
  46. package/src/lib/engine/mail-gateway.js.map +1 -1
  47. package/src/lib/enrollment/enrollment-search.d.ts +32 -0
  48. package/src/lib/enrollment/enrollment-search.js +40 -0
  49. package/src/lib/enrollment/enrollment-search.js.map +1 -0
  50. package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
  51. package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
  52. package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
  53. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  54. package/src/lib/model/enrollment-engagement.js +40 -3
  55. package/src/lib/model/enrollment-engagement.js.map +1 -1
  56. package/src/lib/model/enrollment-history.d.ts +40 -0
  57. package/src/lib/model/enrollment-history.js +95 -0
  58. package/src/lib/model/enrollment-history.js.map +1 -0
  59. package/src/lib/model/enrollment-list-query.d.ts +28 -0
  60. package/src/lib/model/enrollment-list-query.js +151 -0
  61. package/src/lib/model/enrollment-list-query.js.map +1 -0
  62. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  63. package/src/lib/model/enrollment-timeline.js +430 -0
  64. package/src/lib/model/enrollment-timeline.js.map +1 -0
  65. package/src/lib/model/outreach-api.d.ts +24 -0
  66. package/src/lib/model/outreach-api.js.map +1 -1
  67. package/src/lib/model/outreach.types.d.ts +90 -29
  68. package/src/lib/model/outreach.types.js +33 -8
  69. package/src/lib/model/outreach.types.js.map +1 -1
  70. package/src/lib/model/sequence-list-query.d.ts +22 -0
  71. package/src/lib/model/sequence-list-query.js +99 -0
  72. package/src/lib/model/sequence-list-query.js.map +1 -0
  73. package/src/lib/routes/enroll-routes.js +34 -3
  74. package/src/lib/routes/enroll-routes.js.map +1 -1
  75. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  76. package/src/lib/routes/enrollment-routes.js +82 -1
  77. package/src/lib/routes/enrollment-routes.js.map +1 -1
  78. package/src/lib/routes/register-routes.js +14 -4
  79. package/src/lib/routes/register-routes.js.map +1 -1
  80. package/src/lib/routes/route-deps.d.ts +3 -1
  81. package/src/lib/routes/route-deps.js.map +1 -1
  82. package/src/lib/routes/sequence-routes.js +6 -1
  83. package/src/lib/routes/sequence-routes.js.map +1 -1
  84. package/src/lib/runtime/click-events.d.ts +10 -5
  85. package/src/lib/runtime/click-events.js +45 -6
  86. package/src/lib/runtime/click-events.js.map +1 -1
  87. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  88. package/src/lib/runtime/gateway-ledger.js +9 -3
  89. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  90. package/src/lib/runtime/host-suspension.d.ts +30 -0
  91. package/src/lib/runtime/host-suspension.js +35 -0
  92. package/src/lib/runtime/host-suspension.js.map +1 -0
  93. package/src/lib/runtime/person-erasure.d.ts +4 -1
  94. package/src/lib/runtime/person-erasure.js +14 -3
  95. package/src/lib/runtime/person-erasure.js.map +1 -1
  96. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  97. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  98. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  99. package/src/lib/runtime/runtime-deps.js.map +1 -1
  100. package/src/lib/runtime/send-job.js +46 -6
  101. package/src/lib/runtime/send-job.js.map +1 -1
  102. package/src/lib/runtime/sync-job.js +4 -0
  103. package/src/lib/runtime/sync-job.js.map +1 -1
  104. package/src/lib/storage/do-not-contact-store.js +6 -1
  105. package/src/lib/storage/do-not-contact-store.js.map +1 -1
  106. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  107. package/src/lib/storage/domain-intel-store.js +138 -226
  108. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -14,22 +14,25 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
18
19
  import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
19
20
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
21
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
20
22
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
21
23
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
22
24
  import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
23
- import { filterListRows, inMemoryListField, listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
24
- import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
25
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
25
26
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
26
- import { useSnackbar } from "@aglyn/shared-ui-snackstack";
27
- import { Button, Card, CardContent, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack, TextField, Typography, useMediaQuery, useTheme, Chip } from "@mui/material";
28
- import { useEffect, useMemo, useState } from "react";
29
- import { OutreachCurateStepDialog, outreachNextEmailStepIndex } from "./curate-step-dialog.js";
30
- import { readOutreachEngagement } from "../model/enrollment-engagement.js";
31
- import { OUTREACH_ENROLLMENT_STATUSES, OUTREACH_TASK_KIND_LABELS } from "../model/outreach.types.js";
27
+ import { Card, CardContent, Chip, Link, Stack, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
28
+ import { useMemo } from "react";
29
+ import { outreachClickCountLabel, outreachClickSummary } from "../model/enrollment-engagement.js";
30
+ import { OUTREACH_ENROLLMENT_CLICK_FIELDS, OUTREACH_ENROLLMENT_LIST_QUERY } from "../model/enrollment-list-query.js";
31
+ import { OUTREACH_ENROLLMENT_STATUSES, OUTREACH_STOP_REASONS, OUTREACH_TASK_KIND_LABELS } from "../model/outreach.types.js";
32
+ import { useOutreachEnrollmentActions } from "./enrollment-actions.js";
33
+ import { OUTREACH_CLICKED_FILTER_VALUE, OUTREACH_UNCLICKED_FILTER_VALUE } from "./enrollment-filter-params.js";
32
34
  import { formatOutreachTime, OUTREACH_ENROLLMENT_STATUS_LABELS, OUTREACH_STOP_REASON_LABELS, OutreachEnrollmentStatusChip, OutreachLoading, OutreachLoadProblem } from "./outreach-ui.js";
35
+ import { useOutreachEnrollmentList } from "./use-outreach-data.js";
33
36
  /** The step an enrollment is on, as a person reads it. */ export function outreachCurrentStepLabel(enrollment, steps) {
34
37
  if (enrollment.status === 'finished' || enrollment.stepIndex >= steps.length) return 'All steps done';
35
38
  const step = steps[enrollment.stepIndex];
@@ -46,63 +49,34 @@ import { formatOutreachTime, OUTREACH_ENROLLMENT_STATUS_LABELS, OUTREACH_STOP_RE
46
49
  var _enrollment_lastSentAtMs, _enrollment_stoppedAtMs, _enrollment_createdAtMs;
47
50
  return Math.max((_enrollment_lastSentAtMs = enrollment.lastSentAtMs) != null ? _enrollment_lastSentAtMs : 0, (_enrollment_stoppedAtMs = enrollment.stoppedAtMs) != null ? _enrollment_stoppedAtMs : 0, (_enrollment_createdAtMs = enrollment.createdAtMs) != null ? _enrollment_createdAtMs : 0);
48
51
  }
49
- /** What a member may do to an enrollment in the status it is in. */ function actionsFor(enrollment, steps) {
50
- const actions = [];
51
- // Rewrite the next email for this one person, while one is still to go.
52
- if (outreachNextEmailStepIndex(enrollment, steps) !== null) actions.push('curate');
53
- if (enrollment.status === 'active') actions.push('pause', 'stop');
54
- if (enrollment.status === 'paused') actions.push('resume', 'stop');
55
- if (enrollment.status !== 'opted_out') actions.push('do_not_contact');
56
- return actions;
57
- }
58
- const ACTION_LABELS = {
59
- curate: 'Curate next step',
60
- pause: 'Pause',
61
- resume: 'Resume',
62
- stop: 'Stop',
63
- do_not_contact: 'Mark do-not-contact'
64
- };
65
52
  /** Whether the step the enrollment is on goes out as this person's own copy (AGL-3324). */ export function outreachStepIsCurated(enrollment) {
66
53
  var _enrollment_stepOverrides;
67
54
  return Boolean((_enrollment_stepOverrides = enrollment.stepOverrides) == null ? void 0 : _enrollment_stepOverrides[String(enrollment.stepIndex)]);
68
55
  }
69
- /** What the snackbar says once an action lands. */ const DONE_LABELS = {
70
- pause: 'Paused.',
71
- resume: 'Resumed.',
72
- stop: 'Stopped.'
73
- };
74
- /** The two actions that end something for good ask first. */ const CONFIRM = {
75
- stop: {
76
- title: 'Stop this enrollment?',
77
- body: 'They won’t get any more steps from this sequence, and they can’t be enrolled in it again.',
78
- label: 'Stop'
79
- },
80
- do_not_contact: {
81
- title: 'Mark do-not-contact?',
82
- body: 'The address goes on your organization’s do-not-contact list, which every sequence checks before ' + 'every send, and they are stopped in every sequence they are in.',
83
- label: 'Mark do-not-contact'
84
- }
85
- };
86
56
  /*
87
- * What the enrollments grid's Filters panel offers (AGL-3317). Status and
88
- * whether the person is a lead are picked; the name, the address and the
89
- * stop reason are typed. `target` and `email` are no column of their own,
90
- * so they are hidden columns the panel can still reach.
91
- */ const ENROLLMENT_FILTER_FIELDS = [
92
- inMemoryListField('status', 'select'),
93
- inMemoryListField('target', 'select'),
94
- inMemoryListField('contactName', 'text'),
95
- inMemoryListField('email', 'text'),
96
- inMemoryListField('stopReason', 'text')
97
- ];
57
+ * What the enrollments grid's Filters panel offers (AGL-3317), each field on
58
+ * the table's Firestore query (AGL-3321, `OUTREACH_ENROLLMENT_LIST_QUERY`).
59
+ * Status, whether the person is a lead and the stop reason are picked; the
60
+ * address is typed whole. `target` and `email` are no column of their own,
61
+ * so they are hidden columns the panel can still reach; the search box
62
+ * reads the person's name and address.
63
+ *
64
+ * A sequence that counts clicks adds two more (AGL-3332), on the same query:
65
+ * whether the person clicked, and a destination they followed — matched
66
+ * whole against the destinations stored on the enrollment, never by a piece
67
+ * of its address, so "followed /pricing" does not also list the people who
68
+ * followed "/pricing/enterprise".
69
+ */ const BASE_FILTER_FIELDS = OUTREACH_ENROLLMENT_LIST_QUERY.fields.filter((field)=>!OUTREACH_ENROLLMENT_CLICK_FIELDS.includes(field.column));
98
70
  const ENROLLMENT_FILTER_HEADERS = {
99
71
  status: 'Status',
100
72
  target: 'Enrolled as',
101
- contactName: 'Name',
73
+ stopReason: 'Stop reason',
102
74
  email: 'Email',
103
- stopReason: 'Stop reason'
75
+ enrolled: 'Enrolled',
76
+ clicked: 'Clicked',
77
+ link: 'Link followed'
104
78
  };
105
- const ENROLLMENT_FILTER_OPTIONS = {
79
+ const BASE_FILTER_OPTIONS = {
106
80
  status: OUTREACH_ENROLLMENT_STATUSES.map((status)=>({
107
81
  value: status,
108
82
  label: OUTREACH_ENROLLMENT_STATUS_LABELS[status]
@@ -116,26 +90,82 @@ const ENROLLMENT_FILTER_OPTIONS = {
116
90
  value: 'lead',
117
91
  label: 'Lead'
118
92
  }
119
- ]
93
+ ],
94
+ stopReason: OUTREACH_STOP_REASONS.map((reason)=>({
95
+ value: reason,
96
+ label: OUTREACH_STOP_REASON_LABELS[reason]
97
+ }))
120
98
  };
121
- /** What the quick search reads on an enrollment row. */ const ENROLLMENT_SEARCH_FIELDS = [
122
- 'contactName',
123
- 'email',
124
- 'step',
125
- 'stopReason'
99
+ const CLICKED_OPTIONS = [
100
+ {
101
+ value: OUTREACH_CLICKED_FILTER_VALUE,
102
+ label: 'Yes'
103
+ },
104
+ {
105
+ value: OUTREACH_UNCLICKED_FILTER_VALUE,
106
+ label: 'No'
107
+ }
126
108
  ];
127
- /** The filter-only columns never show. */ const ENROLLMENT_HIDDEN_COLUMNS = hiddenFilterVisibility(ENROLLMENT_FILTER_FIELDS, [
128
- 'status',
129
- 'stopReason'
130
- ]);
109
+ /** Columns a reader can show from the column chooser, off until they do (AGL-3332). */ const CLICK_DETAIL_COLUMNS = [
110
+ 'linksFollowed',
111
+ 'lastClick',
112
+ 'scannerClicks'
113
+ ];
114
+ /** The table's grammar, with the click fields when the sequence counts clicks. */ function filterFieldsFor(trackClicks) {
115
+ return trackClicks ? OUTREACH_ENROLLMENT_LIST_QUERY.fields : BASE_FILTER_FIELDS;
116
+ }
117
+ /** What the grid's cells and its export read off one enrollment. */ function filterRow(enrollment, steps, clicks) {
118
+ return {
119
+ enrollment,
120
+ status: enrollment.status,
121
+ target: enrollment.target === 'lead' ? 'lead' : 'contact',
122
+ contactName: enrollment.contactName,
123
+ email: enrollment.email,
124
+ step: outreachCurrentStepLabel(enrollment, steps),
125
+ stopReason: outreachStopLabel(enrollment),
126
+ clicked: clicks.clicks > 0 ? OUTREACH_CLICKED_FILTER_VALUE : OUTREACH_UNCLICKED_FILTER_VALUE,
127
+ link: clicks.followed
128
+ };
129
+ }
130
+ /** The stop reason: its short label, with every word of it on hover and on the person's page. */ function StopReasonCell(props) {
131
+ var _OUTREACH_STOP_REASON_LABELS_enrollment_stopReason;
132
+ const { enrollment } = props;
133
+ if (!enrollment.stopReason) {
134
+ return /*#__PURE__*/ _jsx(Typography, {
135
+ variant: "body2",
136
+ color: "text.secondary",
137
+ children: "—"
138
+ });
139
+ }
140
+ const label = (_OUTREACH_STOP_REASON_LABELS_enrollment_stopReason = OUTREACH_STOP_REASON_LABELS[enrollment.stopReason]) != null ? _OUTREACH_STOP_REASON_LABELS_enrollment_stopReason : '';
141
+ const text = /*#__PURE__*/ _jsx(Typography, {
142
+ variant: "body2",
143
+ sx: _extends({
144
+ whiteSpace: 'normal',
145
+ lineHeight: 1.3
146
+ }, enrollment.stopDetail ? {
147
+ textDecoration: 'underline dotted',
148
+ textUnderlineOffset: 3,
149
+ cursor: 'help'
150
+ } : {}),
151
+ children: label
152
+ });
153
+ return enrollment.stopDetail ? /*#__PURE__*/ _jsx(Tooltip, {
154
+ title: outreachStopLabel(enrollment),
155
+ describeChild: true,
156
+ children: text
157
+ }) : text;
158
+ }
131
159
  /** The table's columns; the person and the actions are drawn from the row's enrollment. */ function columns(rowActions, trackClicks) {
132
160
  return [
133
161
  {
134
162
  field: 'person',
135
163
  headerName: 'Person',
136
- flex: 1,
137
- minWidth: 200,
164
+ flex: 1.2,
165
+ minWidth: 190,
138
166
  sortable: false,
167
+ // What the export writes for a column that is drawn rather than read.
168
+ valueGetter: (_value, row)=>row.enrollment.contactName ? `${row.enrollment.contactName} <${row.enrollment.email}>` : row.enrollment.email,
139
169
  renderCell: ({ row })=>/*#__PURE__*/ _jsxs(Stack, {
140
170
  spacing: 0,
141
171
  sx: {
@@ -176,7 +206,7 @@ const ENROLLMENT_FILTER_OPTIONS = {
176
206
  {
177
207
  field: 'status',
178
208
  headerName: 'Status',
179
- width: 120,
209
+ width: 110,
180
210
  renderCell: ({ row })=>/*#__PURE__*/ _jsx(OutreachEnrollmentStatusChip, {
181
211
  status: row.status
182
212
  })
@@ -212,158 +242,189 @@ const ENROLLMENT_FILTER_OPTIONS = {
212
242
  {
213
243
  field: 'nextSend',
214
244
  headerName: 'Next send',
215
- width: 190,
245
+ width: 170,
216
246
  sortable: false
217
247
  },
218
248
  {
219
249
  field: 'lastActivity',
220
250
  headerName: 'Last activity',
221
- width: 190,
251
+ width: 170,
222
252
  sortable: false
223
253
  },
224
254
  ...trackClicks ? [
225
255
  {
226
256
  field: 'clicks',
227
257
  headerName: 'Clicks',
228
- width: 130,
258
+ description: 'A person’s clicks, and how many different links they followed. Scanners are not counted.',
259
+ width: 110,
229
260
  sortable: false,
230
- renderCell: ({ row })=>{
231
- const engagement = readOutreachEngagement(row.enrollment.engagement);
232
- if (!engagement.clicks) {
233
- return /*#__PURE__*/ _jsx(Typography, {
234
- variant: "body2",
235
- color: "text.secondary",
236
- children: "—"
237
- });
238
- }
239
- return /*#__PURE__*/ _jsxs(Stack, {
240
- spacing: 0,
241
- sx: {
242
- justifyContent: 'center',
243
- height: '100%'
244
- },
245
- children: [
246
- /*#__PURE__*/ _jsx(Typography, {
247
- variant: "body2",
248
- children: engagement.clicks === 1 ? '1 click' : `${engagement.clicks} clicks`
249
- }),
250
- engagement.lastClickUrl ? /*#__PURE__*/ _jsx(Typography, {
251
- variant: "caption",
252
- color: "text.secondary",
253
- noWrap: true,
254
- children: engagement.lastClickUrl
255
- }) : null
256
- ]
257
- });
258
- }
261
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(Typography, {
262
+ variant: "body2",
263
+ color: row.clicks === '—' ? 'text.secondary' : undefined,
264
+ children: row.clicks
265
+ })
266
+ },
267
+ {
268
+ field: 'linksFollowed',
269
+ headerName: 'Links followed',
270
+ flex: 1,
271
+ minWidth: 220,
272
+ sortable: false
273
+ },
274
+ {
275
+ field: 'lastClick',
276
+ headerName: 'Last click',
277
+ width: 170,
278
+ sortable: false
279
+ },
280
+ {
281
+ field: 'scannerClicks',
282
+ headerName: 'Scanner clicks',
283
+ description: 'Clicks a security scanner made, which are not counted as the person’s.',
284
+ width: 130,
285
+ sortable: false
259
286
  }
260
287
  ] : [],
261
288
  {
262
289
  field: 'stopReason',
263
290
  headerName: 'Stop reason',
264
291
  flex: 1,
265
- minWidth: 170
292
+ minWidth: 170,
293
+ renderCell: ({ row })=>/*#__PURE__*/ _jsx(StopReasonCell, {
294
+ enrollment: row.enrollment
295
+ })
266
296
  },
267
297
  listActionsColumn((row)=>rowActions(row.enrollment), {
268
298
  width: 72
269
299
  })
270
300
  ];
271
301
  }
302
+ /** A plain click on a link, which the page handles itself; any other is the browser's. */ const plainClick = (event)=>!event.defaultPrevented && event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
272
303
  /**
273
304
  * A sequence's enrollments (AGL-2980): who is in it, where each person
274
305
  * stands, when their next step goes out in the mailbox's timezone, and the
275
306
  * four things a member can do about it — pause, resume, stop, and mark
276
307
  * do-not-contact. A table on wider screens, a list of cards on a phone.
308
+ *
309
+ * Each row opens the person's own page (AGL-3332), which holds everything
310
+ * else known about them: so a cell says one thing, whole, and the detail
311
+ * behind it is a click away rather than cut off at the column's edge.
277
312
  */ export function OutreachEnrollmentsTable(props) {
278
- var _CONFIRM_confirming_action, _CONFIRM_confirming_action1, _CONFIRM_confirming_action2;
279
- const { enrollments, steps, timeZone, api } = props;
313
+ const { steps, timeZone, api, onOpen, hrefFor } = props;
280
314
  const trackClicks = props.trackClicks === true;
281
315
  const theme = useTheme();
282
316
  const narrow = useMediaQuery(theme.breakpoints.down('md'));
283
- const { enqueueSnackbar } = useSnackbar();
284
- const [confirming, setConfirming] = useState(null);
285
- const [detail, setDetail] = useState('');
286
- const [busy, setBusy] = useState(null);
287
- const [curating, setCurating] = useState(null);
288
- const [page, setPage] = useState(0);
289
- const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
290
- const gridFilter = useListGridFilter({
317
+ const actions = useOutreachEnrollmentActions({
318
+ api,
319
+ steps
320
+ });
321
+ const fields = useMemo(()=>filterFieldsFor(trackClicks), [
322
+ trackClicks
323
+ ]);
324
+ const gridFilter = useListGridFilter(_extends({
291
325
  selectFields: [
292
326
  'status',
293
- 'target'
327
+ 'target',
328
+ 'stopReason',
329
+ 'clicked',
330
+ 'link'
294
331
  ]
295
- });
296
- const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.length > 0;
332
+ }, props.clauses !== undefined ? {
333
+ clauses: props.clauses,
334
+ onChange: props.onClausesChange
335
+ } : {}));
336
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
297
337
  /*
298
- * The rows the grid is handed, every clause and the search answered over
299
- * the enrollments read so far — a window that grows as the reader pages
300
- * past it, so a filter reaches further the further they go.
301
- */ const searchKey = gridFilter.searchWords.join(' ');
302
- const loadedData = enrollments.data;
303
- const matching = useMemo(()=>filterListRows(loadedData.map((enrollment)=>({
304
- enrollment,
305
- status: enrollment.status,
306
- target: enrollment.target === 'lead' ? 'lead' : 'contact',
307
- contactName: enrollment.contactName,
308
- email: enrollment.email,
309
- step: outreachCurrentStepLabel(enrollment, steps),
310
- stopReason: outreachStopLabel(enrollment)
311
- })), ENROLLMENT_FILTER_FIELDS, gridFilter.clauses, {
312
- paths: ENROLLMENT_SEARCH_FIELDS,
313
- words: gridFilter.searchWords
314
- }).map((row)=>row.enrollment), // `searchKey` stands for the words, which are a new array each render.
315
- // eslint-disable-next-line react-hooks/exhaustive-deps
316
- [
317
- loadedData,
318
- steps,
319
- gridFilter.clauses,
320
- searchKey
321
- ]);
322
- // A narrowed list starts again at its first page.
323
- useEffect(()=>setPage(0), [
324
- gridFilter.clauses,
325
- searchKey
338
+ * One page of the sequence's enrollments, newest enrolled first, with
339
+ * every clause and the search word on the query (AGL-3321): a filter
340
+ * reaches the enrollment on page forty as surely as the one on page one,
341
+ * and nothing is matched over the rows that happen to be loaded. The
342
+ * click filters (AGL-3332) are on it too, answered by the `clicked` flag
343
+ * and the destinations the click route stores on each enrollment.
344
+ */ const enrollments = useOutreachEnrollmentList(props.orgId, props.sequenceId, {
345
+ clauses: gridFilter.clauses,
346
+ search: gridFilter.searchWords
347
+ });
348
+ const loadedData = enrollments.rows;
349
+ const summaries = useMemo(()=>new Map(loadedData.map((enrollment)=>[
350
+ enrollment.id,
351
+ outreachClickSummary(enrollment.engagement)
352
+ ])), [
353
+ loadedData
326
354
  ]);
327
- const act = async (enrollment, action, why)=>{
328
- setBusy(enrollment.id);
329
- try {
330
- const answer = await api.actOnEnrollment(enrollment.id, action, why);
331
- const others = answer.stoppedOthers;
332
- enqueueSnackbar(action === 'do_not_contact' ? `Marked do-not-contact${others ? `, and stopped in ${others} other ${others === 1 ? 'sequence' : 'sequences'}` : ''}.` : answer.changed ? DONE_LABELS[action] : 'Nothing to change.', {
333
- variant: 'success'
334
- });
335
- } catch (error) {
336
- enqueueSnackbar(error.message, {
337
- variant: 'error',
338
- allowDuplicate: true
339
- });
340
- } finally{
341
- setBusy(null);
342
- }
355
+ const clicksOf = (enrollment)=>{
356
+ var _summaries_get;
357
+ return (_summaries_get = summaries.get(enrollment.id)) != null ? _summaries_get : outreachClickSummary(enrollment.engagement);
343
358
  };
344
- const choose = (enrollment, action)=>{
345
- if (action === 'curate') {
346
- setCurating(enrollment);
347
- } else if (CONFIRM[action]) {
348
- setDetail('');
349
- setConfirming({
350
- enrollment,
351
- action
352
- });
353
- } else {
354
- void act(enrollment, action);
359
+ /*
360
+ * The destinations the "Link followed" filter offers: the sequence's own
361
+ * rollup, and any a loaded person followed that it does not list — the
362
+ * rollup is capped, and a filter that could not name a destination a
363
+ * person followed would hide them from it.
364
+ */ const linkOptions = useMemo(()=>{
365
+ var _props_linkOptions;
366
+ const options = new Map();
367
+ for (const option of (_props_linkOptions = props.linkOptions) != null ? _props_linkOptions : [])options.set(option.value, option.label);
368
+ for (const summary of summaries.values()){
369
+ for (const url of summary.followed)if (!options.has(url)) options.set(url, url);
355
370
  }
356
- };
357
- if (enrollments.status === 'loading') return /*#__PURE__*/ _jsx(OutreachLoading, {
371
+ return [
372
+ ...options
373
+ ].map(([value, label])=>({
374
+ value,
375
+ label
376
+ }));
377
+ }, [
378
+ props.linkOptions,
379
+ summaries
380
+ ]);
381
+ const filterOptions = useMemo(()=>trackClicks ? _extends({}, BASE_FILTER_OPTIONS, {
382
+ clicked: CLICKED_OPTIONS,
383
+ link: linkOptions
384
+ }) : BASE_FILTER_OPTIONS, [
385
+ trackClicks,
386
+ linkOptions
387
+ ]);
388
+ const hiddenColumns = useMemo(()=>_extends({}, hiddenFilterVisibility(fields, [
389
+ 'status',
390
+ 'stopReason'
391
+ ]), trackClicks ? Object.fromEntries(CLICK_DETAIL_COLUMNS.map((column)=>[
392
+ column,
393
+ false
394
+ ])) : {}), [
395
+ fields,
396
+ trackClicks
397
+ ]);
398
+ /*
399
+ * A person who followed this destination before each click was recorded
400
+ * on its own, and another link after it, is known by their LAST link only
401
+ * — so a "Link followed" filter says it may miss them rather than
402
+ * implying the list is whole. Which people those are is exactly what the
403
+ * query cannot see, so the caveat stands whenever the filter does.
404
+ */ const linkFilterMayMiss = trackClicks && gridFilter.clauses.some((clause)=>clause.field === 'link');
405
+ const chips = /*#__PURE__*/ _jsx(ListFilterChips, {
406
+ fields: fields,
407
+ headers: ENROLLMENT_FILTER_HEADERS,
408
+ clauses: gridFilter.clauses,
409
+ onChange: gridFilter.setClauses,
410
+ options: filterOptions
411
+ });
412
+ if (enrollments.status === 'loading' && !filtering) return /*#__PURE__*/ _jsx(OutreachLoading, {
358
413
  label: "Loading enrollments…"
359
414
  });
360
415
  if (enrollments.status === 'error' || enrollments.status === 'refused') {
361
- return /*#__PURE__*/ _jsx(OutreachLoadProblem, {
362
- status: enrollments.status,
363
- what: "enrollments"
416
+ return /*#__PURE__*/ _jsxs(Stack, {
417
+ spacing: 1.5,
418
+ children: [
419
+ chips,
420
+ /*#__PURE__*/ _jsx(OutreachLoadProblem, {
421
+ status: enrollments.status,
422
+ what: "enrollments"
423
+ })
424
+ ]
364
425
  });
365
426
  }
366
- if (!enrollments.data.length) {
427
+ if (enrollments.status === 'ready' && !enrollments.rows.length && !filtering && enrollments.page === 0) {
367
428
  return /*#__PURE__*/ _jsx(Card, {
368
429
  variant: "outlined",
369
430
  children: /*#__PURE__*/ _jsx(EmptyStateComponent, {
@@ -375,15 +436,27 @@ const ENROLLMENT_FILTER_OPTIONS = {
375
436
  }
376
437
  const rowActions = (enrollment)=>/*#__PURE__*/ _jsx(ListRowActions, {
377
438
  label: enrollment.contactName || enrollment.email,
378
- items: actionsFor(enrollment, steps).map((action)=>({
379
- key: action,
380
- label: ACTION_LABELS[action],
381
- onClick: ()=>choose(enrollment, action),
382
- destructive: action === 'stop' || action === 'do_not_contact',
383
- disabled: busy === enrollment.id
384
- }))
439
+ items: actions.menuItems(enrollment)
385
440
  });
386
441
  const nextSend = (enrollment)=>enrollment.status === 'active' || enrollment.status === 'paused' ? formatOutreachTime(enrollment.nextDueAtMs, timeZone) : '—';
442
+ const personName = (enrollment)=>{
443
+ const name = enrollment.contactName || enrollment.email;
444
+ if (!hrefFor) return /*#__PURE__*/ _jsx(Typography, {
445
+ variant: "body2",
446
+ children: name
447
+ });
448
+ return /*#__PURE__*/ _jsx(Link, {
449
+ href: hrefFor(enrollment),
450
+ variant: "body2",
451
+ underline: "hover",
452
+ onClick: (event)=>{
453
+ if (!onOpen || !plainClick(event)) return;
454
+ event.preventDefault();
455
+ onOpen(enrollment);
456
+ },
457
+ children: name
458
+ });
459
+ };
387
460
  const person = (enrollment)=>/*#__PURE__*/ _jsxs(Stack, {
388
461
  spacing: 0,
389
462
  children: [
@@ -394,10 +467,7 @@ const ENROLLMENT_FILTER_OPTIONS = {
394
467
  alignItems: 'center'
395
468
  },
396
469
  children: [
397
- /*#__PURE__*/ _jsx(Typography, {
398
- variant: "body2",
399
- children: enrollment.contactName || enrollment.email
400
- }),
470
+ personName(enrollment),
401
471
  enrollment.target === 'lead' ? /*#__PURE__*/ _jsx(Chip, {
402
472
  size: "small",
403
473
  variant: "outlined",
@@ -412,27 +482,23 @@ const ENROLLMENT_FILTER_OPTIONS = {
412
482
  }) : null
413
483
  ]
414
484
  });
415
- const loaded = matching;
416
- const pageRows = loaded.slice(page * pageSize, (page + 1) * pageSize);
417
- const lastLoadedPage = Math.max(0, Math.ceil(loaded.length / pageSize) - 1);
418
- /** Past the rows read so far, the next page reads another window first. */ const turnTo = (next)=>{
419
- if ((next + 1) * pageSize > loaded.length && enrollments.hasMore) enrollments.showMore();
420
- setPage(next);
421
- };
485
+ const pageRows = enrollments.rows;
422
486
  return /*#__PURE__*/ _jsxs(Stack, {
423
487
  spacing: 1.5,
424
488
  children: [
425
- /*#__PURE__*/ _jsx(ListFilterChips, {
426
- fields: ENROLLMENT_FILTER_FIELDS,
427
- headers: ENROLLMENT_FILTER_HEADERS,
428
- clauses: gridFilter.clauses,
429
- onChange: gridFilter.setClauses,
430
- options: ENROLLMENT_FILTER_OPTIONS
489
+ chips,
490
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
491
+ refused: listQueryRefusals(enrollments.plan.refused, {
492
+ fields,
493
+ headers: ENROLLMENT_FILTER_HEADERS,
494
+ options: filterOptions
495
+ }),
496
+ notices: enrollments.plan.notices
431
497
  }),
432
- filtering && enrollments.hasMore ? /*#__PURE__*/ _jsx(Typography, {
498
+ linkFilterMayMiss ? /*#__PURE__*/ _jsx(Typography, {
433
499
  variant: "caption",
434
500
  color: "text.secondary",
435
- children: `Filtering the ${enrollments.data.length} enrollments read so far — the next page reads more.`
501
+ children: "Clicks made before each one was recorded on its own kept only the person’s last link, so someone who followed this link and then another back then is not listed here."
436
502
  }) : null,
437
503
  narrow ? /*#__PURE__*/ _jsx(Stack, {
438
504
  spacing: 1,
@@ -443,7 +509,9 @@ const ENROLLMENT_FILTER_OPTIONS = {
443
509
  m: 0
444
510
  },
445
511
  "aria-label": "Enrollments",
446
- children: pageRows.map((enrollment)=>/*#__PURE__*/ _jsx(Card, {
512
+ children: pageRows.map((enrollment)=>{
513
+ const clicks = clicksOf(enrollment);
514
+ return /*#__PURE__*/ _jsx(Card, {
447
515
  variant: "outlined",
448
516
  component: "li",
449
517
  children: /*#__PURE__*/ _jsx(CardContent, {
@@ -495,10 +563,10 @@ const ENROLLMENT_FILTER_OPTIONS = {
495
563
  color: "text.secondary",
496
564
  children: `Next send: ${nextSend(enrollment)} · Last activity: ${formatOutreachTime(lastActivityMs(enrollment), timeZone)}`
497
565
  }),
498
- trackClicks && readOutreachEngagement(enrollment.engagement).clicks ? /*#__PURE__*/ _jsx(Typography, {
566
+ trackClicks && clicks.clicks ? /*#__PURE__*/ _jsx(Typography, {
499
567
  variant: "body2",
500
568
  color: "text.secondary",
501
- children: `Clicked ${readOutreachEngagement(enrollment.engagement).clicks} ${readOutreachEngagement(enrollment.engagement).clicks === 1 ? 'time' : 'times'}`
569
+ children: `Clicks: ${outreachClickCountLabel(clicks)}`
502
570
  }) : null,
503
571
  outreachStopLabel(enrollment) ? /*#__PURE__*/ _jsx(Typography, {
504
572
  variant: "body2",
@@ -508,104 +576,56 @@ const ENROLLMENT_FILTER_OPTIONS = {
508
576
  ]
509
577
  })
510
578
  })
511
- }, enrollment.id))
579
+ }, enrollment.id);
580
+ })
512
581
  }) : /*#__PURE__*/ _jsx(ListTable, {
513
582
  "aria-label": "Enrollments",
514
- columns: listFilterGridColumns(columns(rowActions, trackClicks), ENROLLMENT_FILTER_FIELDS, ENROLLMENT_FILTER_OPTIONS, ENROLLMENT_FILTER_HEADERS),
583
+ columns: listFilterGridColumns(columns(rowActions, trackClicks), fields, filterOptions, ENROLLMENT_FILTER_HEADERS),
515
584
  initialState: {
516
585
  columns: {
517
- columnVisibilityModel: ENROLLMENT_HIDDEN_COLUMNS
586
+ columnVisibilityModel: hiddenColumns
518
587
  }
519
588
  },
520
- rows: pageRows.map((enrollment)=>({
521
- $id: enrollment.id,
522
- enrollment,
523
- status: enrollment.status,
524
- target: enrollment.target === 'lead' ? 'lead' : 'contact',
525
- contactName: enrollment.contactName,
526
- email: enrollment.email,
527
- step: outreachCurrentStepLabel(enrollment, steps),
589
+ loading: enrollments.status === 'loading',
590
+ rows: pageRows.map((enrollment)=>{
591
+ var _enrollment_engagement;
592
+ const clicks = clicksOf(enrollment);
593
+ return _extends({
594
+ $id: enrollment.id
595
+ }, filterRow(enrollment, steps, clicks), {
528
596
  nextSend: nextSend(enrollment),
529
597
  lastActivity: formatOutreachTime(lastActivityMs(enrollment), timeZone),
598
+ // What the export writes; the cells draw these their own way.
599
+ clicks: outreachClickCountLabel(clicks),
600
+ linksFollowed: clicks.followed.join(' ') || '—',
601
+ lastClick: clicks.clicks ? formatOutreachTime((_enrollment_engagement = enrollment.engagement) == null ? void 0 : _enrollment_engagement.lastClickAtMs, timeZone) : '—',
602
+ scannerClicks: clicks.machineClicks ? String(clicks.machineClicks) : '—',
530
603
  stopReason: outreachStopLabel(enrollment) || '—'
531
- })),
604
+ });
605
+ }),
606
+ onOpen: onOpen ? (_id, row)=>onOpen(row.enrollment) : undefined,
532
607
  /*
533
- * The panel and the search are the grid's; the table answers them
534
- * over the enrollments it read (AGL-3317).
608
+ * The panel and the search are the grid's; every clause and the
609
+ * search word are on the table's query (AGL-3321). The grid
610
+ * neither filters nor sorts the page it is handed: the query's
611
+ * one order, newest enrolled first, is the table's.
535
612
  */ filterMode: "server",
536
613
  filterModel: gridFilter.filterModel,
537
614
  onFilterModelChange: gridFilter.onFilterModelChange,
538
615
  quickFilter: true,
616
+ disableColumnSorting: true,
539
617
  noRowsLabel: "No enrollments match these filters",
540
618
  hideFooter: true
541
619
  }),
542
620
  /*#__PURE__*/ _jsx(ListPagination, {
543
- page: page,
544
- pageSize: pageSize,
621
+ page: enrollments.page,
622
+ pageSize: enrollments.pageSize,
545
623
  rowCount: pageRows.length,
546
- count: enrollments.hasMore ? undefined : loaded.length,
547
- hasMore: enrollments.hasMore || page < lastLoadedPage,
548
- onPageChange: turnTo,
549
- onPageSizeChange: setPageSize
624
+ hasMore: enrollments.hasMore,
625
+ onPageChange: enrollments.setPage,
626
+ onPageSizeChange: enrollments.setPageSize
550
627
  }),
551
- /*#__PURE__*/ _jsx(OutreachCurateStepDialog, {
552
- enrollment: curating,
553
- steps: steps,
554
- api: api,
555
- onClose: ()=>setCurating(null)
556
- }),
557
- /*#__PURE__*/ _jsx(Dialog, {
558
- open: Boolean(confirming),
559
- onClose: ()=>setConfirming(null),
560
- fullWidth: true,
561
- maxWidth: "xs",
562
- children: confirming ? /*#__PURE__*/ _jsxs(_Fragment, {
563
- children: [
564
- /*#__PURE__*/ _jsx(DialogTitle, {
565
- children: (_CONFIRM_confirming_action = CONFIRM[confirming.action]) == null ? void 0 : _CONFIRM_confirming_action.title
566
- }),
567
- /*#__PURE__*/ _jsx(DialogContent, {
568
- children: /*#__PURE__*/ _jsxs(Stack, {
569
- spacing: 2,
570
- children: [
571
- /*#__PURE__*/ _jsx(DialogContentText, {
572
- children: (_CONFIRM_confirming_action1 = CONFIRM[confirming.action]) == null ? void 0 : _CONFIRM_confirming_action1.body
573
- }),
574
- /*#__PURE__*/ _jsx(TextField, {
575
- label: "Why (optional)",
576
- value: detail,
577
- onChange: (event)=>setDetail(event.target.value),
578
- fullWidth: true,
579
- slotProps: {
580
- htmlInput: {
581
- maxLength: 200
582
- }
583
- }
584
- })
585
- ]
586
- })
587
- }),
588
- /*#__PURE__*/ _jsxs(DialogActions, {
589
- children: [
590
- /*#__PURE__*/ _jsx(Button, {
591
- onClick: ()=>setConfirming(null),
592
- children: "Cancel"
593
- }),
594
- /*#__PURE__*/ _jsx(Button, {
595
- color: "error",
596
- variant: "contained",
597
- onClick: ()=>{
598
- const { enrollment, action } = confirming;
599
- setConfirming(null);
600
- void act(enrollment, action, detail.trim() || undefined);
601
- },
602
- children: (_CONFIRM_confirming_action2 = CONFIRM[confirming.action]) == null ? void 0 : _CONFIRM_confirming_action2.label
603
- })
604
- ]
605
- })
606
- ]
607
- }) : null
608
- })
628
+ actions.dialogs
609
629
  ]
610
630
  });
611
631
  }