@aglyn/plugins-outreach 1.0.0-beta.198 → 1.0.0-beta.200

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 (34) 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/enrollments-table.d.ts +4 -2
  5. package/src/lib/components/enrollments-table.js +70 -89
  6. package/src/lib/components/enrollments-table.js.map +1 -1
  7. package/src/lib/components/sequence-detail.js +3 -3
  8. package/src/lib/components/sequence-detail.js.map +1 -1
  9. package/src/lib/components/sequences-section.d.ts +9 -0
  10. package/src/lib/components/sequences-section.js +96 -82
  11. package/src/lib/components/sequences-section.js.map +1 -1
  12. package/src/lib/components/use-outreach-data.d.ts +30 -25
  13. package/src/lib/components/use-outreach-data.js +93 -104
  14. package/src/lib/components/use-outreach-data.js.map +1 -1
  15. package/src/lib/enrollment/enrollment-search.d.ts +32 -0
  16. package/src/lib/enrollment/enrollment-search.js +40 -0
  17. package/src/lib/enrollment/enrollment-search.js.map +1 -0
  18. package/src/lib/model/do-not-contact-domain-list-query.d.ts +32 -0
  19. package/src/lib/model/do-not-contact-domain-list-query.js +107 -0
  20. package/src/lib/model/do-not-contact-domain-list-query.js.map +1 -0
  21. package/src/lib/model/enrollment-list-query.d.ts +22 -0
  22. package/src/lib/model/enrollment-list-query.js +117 -0
  23. package/src/lib/model/enrollment-list-query.js.map +1 -0
  24. package/src/lib/model/outreach.types.d.ts +12 -0
  25. package/src/lib/model/outreach.types.js.map +1 -1
  26. package/src/lib/model/sequence-list-query.d.ts +22 -0
  27. package/src/lib/model/sequence-list-query.js +99 -0
  28. package/src/lib/model/sequence-list-query.js.map +1 -0
  29. package/src/lib/routes/enroll-routes.js +8 -0
  30. package/src/lib/routes/enroll-routes.js.map +1 -1
  31. package/src/lib/routes/sequence-routes.js +6 -1
  32. package/src/lib/routes/sequence-routes.js.map +1 -1
  33. package/src/lib/storage/do-not-contact-store.js +6 -1
  34. package/src/lib/storage/do-not-contact-store.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-outreach",
3
- "version": "1.0.0-beta.198",
3
+ "version": "1.0.0-beta.200",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.198",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.198",
30
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.198",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.198",
32
- "@aglyn/shared-ui-next": "1.0.0-beta.198",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.198",
34
- "@aglyn/shared-util-email": "1.0.0-beta.198",
35
- "@aglyn/shared-util-http": "1.0.0-beta.198",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.198",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.198",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.198",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.198",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.198",
28
+ "@aglyn/aglyn": "1.0.0-beta.200",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.200",
30
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.200",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.200",
32
+ "@aglyn/shared-ui-next": "1.0.0-beta.200",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.200",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.200",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.200",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.200",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.200",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.200",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.200",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.200",
41
41
  "@swc/helpers": "0.5.23"
42
42
  },
43
43
  "peerDependencies": {
@@ -19,17 +19,20 @@ import { pluginDocsHelp } from "@aglyn/aglyn";
19
19
  import { mdiTrashCanOutline } from "@aglyn/shared-data-mdi";
20
20
  import { CardDisplay, MdiIcon } from "@aglyn/shared-ui-jsx";
21
21
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
22
+ import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
23
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
22
24
  import { ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
23
- import { filterListRows, inMemoryListField, listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
25
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
24
26
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
25
27
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
26
28
  import { Button, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
27
- import { useMemo, useState } from "react";
29
+ import { useState } from "react";
28
30
  import { normalizeOutreachDomain } from "../engine/do-not-contact-domain.js";
31
+ import { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from "../model/do-not-contact-domain-list-query.js";
29
32
  import { OUTREACH_DO_NOT_CONTACT_REASONS } from "../model/outreach.types.js";
30
33
  import { OutreachLoading, OutreachLoadProblem } from "./outreach-ui.js";
31
34
  import { useOutreachApi } from "./use-outreach-api.js";
32
- import { useOutreachDoNotContactDomains } from "./use-outreach-data.js";
35
+ import { useOutreachDoNotContactDomainList } from "./use-outreach-data.js";
33
36
  /** Why a domain is on the list, as the card says it. */ export const OUTREACH_DO_NOT_CONTACT_REASON_LABELS = {
34
37
  manual: 'Added by a member',
35
38
  opt_out_reply: 'A reply asked not to be emailed',
@@ -44,17 +47,15 @@ import { useOutreachDoNotContactDomains } from "./use-outreach-data.js";
44
47
  };
45
48
  /** What the field says under a value that is not a domain. */ export const DO_NOT_CONTACT_DOMAIN_HINT = 'A domain, such as example.com. Every address at it is refused.';
46
49
  /*
47
- * What the domains grid's Filters panel offers (AGL-3317). The card reads
48
- * every listed domain, so the panel and the search answer over all of them.
49
- */ const DOMAIN_FILTER_FIELDS = [
50
- inMemoryListField('domain', 'text'),
51
- inMemoryListField('reason', 'select'),
52
- inMemoryListField('detail', 'text')
53
- ];
50
+ * What the domains grid's Filters panel offers (AGL-3317), each field on the
51
+ * list's Firestore query (AGL-3321, `OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY`):
52
+ * the whole domain, why it is listed, and when it was added. The search box
53
+ * reads the domain, each of its labels and the words of its detail.
54
+ */ const DOMAIN_FILTER_FIELDS = OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY.fields;
54
55
  const DOMAIN_FILTER_HEADERS = {
55
56
  domain: 'Domain',
56
57
  reason: 'Why',
57
- detail: 'Detail'
58
+ addedAtMs: 'Added'
58
59
  };
59
60
  const DOMAIN_FILTER_OPTIONS = {
60
61
  reason: OUTREACH_DO_NOT_CONTACT_REASONS.map((reason)=>({
@@ -62,10 +63,6 @@ const DOMAIN_FILTER_OPTIONS = {
62
63
  label: OUTREACH_DO_NOT_CONTACT_REASON_LABELS[reason]
63
64
  }))
64
65
  };
65
- /** What the quick search reads on a domain row. */ const DOMAIN_SEARCH_FIELDS = [
66
- 'domain',
67
- 'detail'
68
- ];
69
66
  function addedOn(entry) {
70
67
  if (!entry.addedAtMs) return '';
71
68
  return new Intl.DateTimeFormat('en-US', {
@@ -140,7 +137,6 @@ function addedOn(entry) {
140
137
  */ export function OutreachDoNotContactDomainsCard(props) {
141
138
  const { orgId } = props;
142
139
  const api = useOutreachApi(orgId);
143
- const listed = useOutreachDoNotContactDomains(orgId);
144
140
  const { enqueueSnackbar } = useSnackbar();
145
141
  const [draft, setDraft] = useState('');
146
142
  const [busy, setBusy] = useState(null);
@@ -151,27 +147,27 @@ function addedOn(entry) {
151
147
  'reason'
152
148
  ]
153
149
  });
154
- const searchKey = gridFilter.searchWords.join(' ');
155
- const rows = useMemo(()=>filterListRows(listed.data.map((entry)=>{
156
- var _entry_addedAtMs, _entry_detail;
157
- return {
158
- $id: entry.domain,
159
- domain: entry.domain,
160
- reason: entry.reason,
161
- added: addedOn(entry),
162
- addedAtMs: (_entry_addedAtMs = entry.addedAtMs) != null ? _entry_addedAtMs : 0,
163
- detail: (_entry_detail = entry.detail) != null ? _entry_detail : ''
164
- };
165
- }), DOMAIN_FILTER_FIELDS, gridFilter.clauses, {
166
- paths: DOMAIN_SEARCH_FIELDS,
167
- words: gridFilter.searchWords
168
- }), // `searchKey` stands for the words, which are a new array each render.
169
- // eslint-disable-next-line react-hooks/exhaustive-deps
170
- [
171
- listed.data,
172
- gridFilter.clauses,
173
- searchKey
174
- ]);
150
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
151
+ /*
152
+ * One page of the domains, alphabetical, with every clause and the search
153
+ * word on the query (AGL-3321) — never matched over the domains that
154
+ * happen to be loaded.
155
+ */ const listed = useOutreachDoNotContactDomainList(orgId, {
156
+ clauses: gridFilter.clauses,
157
+ search: gridFilter.searchWords
158
+ });
159
+ const rows = listed.rows.map((entry)=>{
160
+ var _entry_addedAtMs, _entry_detail;
161
+ return {
162
+ $id: entry.domain,
163
+ domain: entry.domain,
164
+ reason: entry.reason,
165
+ added: addedOn(entry),
166
+ addedAtMs: (_entry_addedAtMs = entry.addedAtMs) != null ? _entry_addedAtMs : 0,
167
+ detail: (_entry_detail = entry.detail) != null ? _entry_detail : ''
168
+ };
169
+ });
170
+ /** Nothing listed at all — not a filter that matched nothing. */ const none = listed.status === 'ready' && !listed.rows.length && !filtering && listed.page === 0;
175
171
  const change = async (action, domain)=>{
176
172
  setBusy(domain);
177
173
  try {
@@ -241,12 +237,12 @@ function addedOn(entry) {
241
237
  })
242
238
  ]
243
239
  }),
244
- listed.status === 'loading' ? /*#__PURE__*/ _jsx(OutreachLoading, {
240
+ listed.status === 'loading' && !filtering ? /*#__PURE__*/ _jsx(OutreachLoading, {
245
241
  label: "Loading domains…"
246
242
  }) : listed.status === 'error' || listed.status === 'refused' ? /*#__PURE__*/ _jsx(OutreachLoadProblem, {
247
243
  status: listed.status,
248
244
  what: "the do-not-contact domains"
249
- }) : listed.data.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
245
+ }) : none ? /*#__PURE__*/ _jsx(Typography, {
250
246
  variant: "body2",
251
247
  color: "text.secondary",
252
248
  children: "No domains yet."
@@ -260,18 +256,40 @@ function addedOn(entry) {
260
256
  onChange: gridFilter.setClauses,
261
257
  options: DOMAIN_FILTER_OPTIONS
262
258
  }),
259
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
260
+ refused: listQueryRefusals(listed.plan.refused, {
261
+ fields: DOMAIN_FILTER_FIELDS,
262
+ headers: DOMAIN_FILTER_HEADERS,
263
+ options: DOMAIN_FILTER_OPTIONS
264
+ }),
265
+ notices: listed.plan.notices
266
+ }),
263
267
  /*#__PURE__*/ _jsx(ListTable, {
264
268
  "aria-label": "Do not contact domains",
265
269
  columns: domainColumns(busy !== null, (domain)=>void change('remove', domain)),
266
270
  rows: rows,
271
+ loading: listed.status === 'loading',
267
272
  /*
268
- * The panel and the search are the grid's; the card answers
269
- * them over every listed domain (AGL-3317).
273
+ * The panel and the search are the grid's; every clause and
274
+ * the search word are on the list's query (AGL-3321). The grid
275
+ * neither filters nor sorts the page it is handed: the query's
276
+ * order — alphabetical, or newest added while a date applies —
277
+ * is the list's.
270
278
  */ filterMode: "server",
271
279
  filterModel: gridFilter.filterModel,
272
280
  onFilterModelChange: gridFilter.onFilterModelChange,
273
281
  quickFilter: true,
274
- noRowsLabel: "No domains match these filters"
282
+ disableColumnSorting: true,
283
+ noRowsLabel: "No domains match these filters",
284
+ hideFooter: true
285
+ }),
286
+ /*#__PURE__*/ _jsx(ListPagination, {
287
+ page: listed.page,
288
+ pageSize: listed.pageSize,
289
+ rowCount: rows.length,
290
+ hasMore: listed.hasMore,
291
+ onPageChange: listed.setPage,
292
+ onPageSizeChange: listed.setPageSize
275
293
  })
276
294
  ]
277
295
  })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/do-not-contact-domains.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiTrashCanOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport {\n ListTable,\n listActionsColumn,\n type ListTableProps,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport {\n filterListRows,\n inMemoryListField,\n listFilterGridColumns,\n} from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Button, Chip, IconButton, Stack, TextField, Typography } from '@mui/material'\nimport { useMemo, useState } from 'react'\nimport { normalizeOutreachDomain } from '../engine/do-not-contact-domain'\nimport {\n OUTREACH_DO_NOT_CONTACT_REASONS,\n type OutreachDoNotContactDomainEntry,\n type OutreachDoNotContactReason,\n} from '../model/outreach.types'\nimport { OutreachLoading, OutreachLoadProblem } from './outreach-ui'\nimport { useOutreachApi } from './use-outreach-api'\nimport { useOutreachDoNotContactDomains } from './use-outreach-data'\n\nexport interface OutreachDoNotContactDomainsCardProps {\n /** The organization the hub is mounted under; `null` until the shell knows it. */\n orgId: string | null\n}\n\n/** Why a domain is on the list, as the card says it. */\nexport const OUTREACH_DO_NOT_CONTACT_REASON_LABELS: Record<OutreachDoNotContactReason, string> = {\n manual: 'Added by a member',\n opt_out_reply: 'A reply asked not to be emailed',\n unsubscribe: 'Unsubscribed',\n hard_bounce: 'Mail bounced',\n gateway_block: 'Its mail gateway blocked the sender',\n}\n\n/** Accessible names of the card's controls, spelled once for the specs. */\nexport const DO_NOT_CONTACT_DOMAIN_LABELS = {\n field: 'Domain',\n add: 'Add domain',\n remove: (domain: string) => `Remove ${domain}`,\n} as const\n\n/** What the field says under a value that is not a domain. */\nexport const DO_NOT_CONTACT_DOMAIN_HINT = 'A domain, such as example.com. Every address at it is refused.'\n\n/*\n * What the domains grid's Filters panel offers (AGL-3317). The card reads\n * every listed domain, so the panel and the search answer over all of them.\n */\nconst DOMAIN_FILTER_FIELDS = [\n inMemoryListField('domain', 'text'),\n inMemoryListField('reason', 'select'),\n inMemoryListField('detail', 'text'),\n]\nconst DOMAIN_FILTER_HEADERS: Readonly<Record<string, string>> = {\n domain: 'Domain',\n reason: 'Why',\n detail: 'Detail',\n}\nconst DOMAIN_FILTER_OPTIONS = {\n reason: OUTREACH_DO_NOT_CONTACT_REASONS.map((reason) => ({\n value: reason,\n label: OUTREACH_DO_NOT_CONTACT_REASON_LABELS[reason],\n })),\n}\n/** What the quick search reads on a domain row. */\nconst DOMAIN_SEARCH_FIELDS = ['domain', 'detail'] as const\n\nfunction addedOn(entry: OutreachDoNotContactDomainEntry): string {\n if (!entry.addedAtMs) return ''\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(entry.addedAtMs)\n}\n\n/** The grid's columns; the remove button names its domain for a screen reader. */\nfunction domainColumns(\n busy: boolean,\n remove: (domain: string) => void,\n): NonNullable<ListTableProps['columns']> {\n return listFilterGridColumns(\n [\n {\n field: 'domain',\n headerName: 'Domain',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" component=\"span\" sx={{ fontWeight: 500 }}>\n {row.domain}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Why',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={row.reason === 'gateway_block' ? 'warning' : 'default'}\n label={\n OUTREACH_DO_NOT_CONTACT_REASON_LABELS[row.reason as OutreachDoNotContactReason] ??\n row.reason\n }\n />\n ),\n },\n {\n field: 'addedAtMs',\n headerName: 'Added',\n width: 140,\n renderCell: ({ row }) => row.added,\n },\n { field: 'detail', headerName: 'Detail', flex: 1, minWidth: 160 },\n listActionsColumn(\n (row) => (\n <IconButton\n size=\"small\"\n aria-label={DO_NOT_CONTACT_DOMAIN_LABELS.remove(row.domain)}\n disabled={busy}\n onClick={() => remove(row.domain)}\n >\n <MdiIcon path={mdiTrashCanOutline.path} fontSize=\"small\" />\n </IconButton>\n ),\n { width: 72 },\n ),\n ],\n DOMAIN_FILTER_FIELDS,\n DOMAIN_FILTER_OPTIONS,\n DOMAIN_FILTER_HEADERS,\n )\n}\n\n/**\n * Sequences → Compliance → Do not contact domains (AGL-3244): the domains no\n * sequence emails anyone at, whoever enrolls them.\n *\n * A member adds one by hand — a company that asked, or one whose gateway is\n * known to block cold mail — and the sending runtime adds one when a hard\n * bounce reads as the domain's gateway refusing the sender. Either way it\n * is listed here with why, and a member takes it off by name.\n */\nexport function OutreachDoNotContactDomainsCard(props: OutreachDoNotContactDomainsCardProps) {\n const { orgId } = props\n const api = useOutreachApi(orgId)\n const listed = useOutreachDoNotContactDomains(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const [draft, setDraft] = useState('')\n const [busy, setBusy] = useState<string | null>(null)\n const normalized = normalizeOutreachDomain(draft)\n const invalid = draft.trim() !== '' && normalized === null\n const gridFilter = useListGridFilter({ selectFields: ['reason'] })\n const searchKey = gridFilter.searchWords.join(' ')\n const rows = useMemo(\n () =>\n filterListRows(\n listed.data.map((entry) => ({\n $id: entry.domain,\n domain: entry.domain,\n reason: entry.reason,\n added: addedOn(entry),\n addedAtMs: entry.addedAtMs ?? 0,\n detail: entry.detail ?? '',\n })),\n DOMAIN_FILTER_FIELDS,\n gridFilter.clauses,\n { paths: DOMAIN_SEARCH_FIELDS, words: gridFilter.searchWords },\n ),\n // `searchKey` stands for the words, which are a new array each render.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [listed.data, gridFilter.clauses, searchKey],\n )\n\n const change = async (action: 'add' | 'remove', domain: string) => {\n setBusy(domain)\n try {\n const answer = await api.changeDoNotContactDomain(action, domain)\n if (action === 'add') setDraft('')\n enqueueSnackbar(\n answer.changed\n ? action === 'add'\n ? `${answer.domain} is on the do-not-contact list.`\n : `${answer.domain} is off the do-not-contact list.`\n : action === 'add'\n ? `${answer.domain} was already on the list.`\n : `${answer.domain} was not on the list.`,\n { variant: answer.changed ? 'success' : 'info' },\n )\n } catch (error) {\n enqueueSnackbar((error as Error).message, { variant: 'error', allowDuplicate: true })\n } finally {\n setBusy(null)\n }\n }\n\n return (\n <CardDisplay\n header=\"Do not contact domains\"\n help={pluginDocsHelp('sequences', { anchor: '#do-not-contact-domains' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No sequence emails anyone at these domains, whoever enrolls them. Add a company that asked not ' +\n 'to hear from you, or one whose mail gateway blocks you. When an email bounces because the ' +\n 'recipient’s gateway refused it — rather than because the address is unknown — the domain is ' +\n 'added here automatically, so the next person at that company is not tried.'}\n </Typography>\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'flex-start' } }}>\n <TextField\n label={DO_NOT_CONTACT_DOMAIN_LABELS.field}\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && normalized && !busy) {\n event.preventDefault()\n void change('add', normalized)\n }\n }}\n error={invalid}\n helperText={invalid ? 'That is not a domain.' : DO_NOT_CONTACT_DOMAIN_HINT}\n size=\"small\"\n sx={{ flexGrow: 1 }}\n />\n <Button\n variant=\"outlined\"\n disabled={!normalized || busy !== null}\n onClick={() => normalized && void change('add', normalized)}\n >\n {DO_NOT_CONTACT_DOMAIN_LABELS.add}\n </Button>\n </Stack>\n {listed.status === 'loading' ? (\n <OutreachLoading label=\"Loading domains…\" />\n ) : listed.status === 'error' || listed.status === 'refused' ? (\n <OutreachLoadProblem status={listed.status} what=\"the do-not-contact domains\" />\n ) : listed.data.length === 0 ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n No domains yet.\n </Typography>\n ) : (\n <Stack spacing={1}>\n <ListFilterChips\n fields={DOMAIN_FILTER_FIELDS}\n headers={DOMAIN_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={DOMAIN_FILTER_OPTIONS}\n />\n <ListTable\n aria-label=\"Do not contact domains\"\n columns={domainColumns(busy !== null, (domain) => void change('remove', domain))}\n rows={rows}\n /*\n * The panel and the search are the grid's; the card answers\n * them over every listed domain (AGL-3317).\n */\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n noRowsLabel=\"No domains match these filters\"\n />\n </Stack>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOutreachDoNotContactDomainsCard.displayName = 'OutreachDoNotContactDomainsCard'\n\nexport default OutreachDoNotContactDomainsCard\n"],"names":["pluginDocsHelp","mdiTrashCanOutline","CardDisplay","MdiIcon","ListFilterChips","ListTable","listActionsColumn","filterListRows","inMemoryListField","listFilterGridColumns","useListGridFilter","useSnackbar","Button","Chip","IconButton","Stack","TextField","Typography","useMemo","useState","normalizeOutreachDomain","OUTREACH_DO_NOT_CONTACT_REASONS","OutreachLoading","OutreachLoadProblem","useOutreachApi","useOutreachDoNotContactDomains","OUTREACH_DO_NOT_CONTACT_REASON_LABELS","manual","opt_out_reply","unsubscribe","hard_bounce","gateway_block","DO_NOT_CONTACT_DOMAIN_LABELS","field","add","remove","domain","DO_NOT_CONTACT_DOMAIN_HINT","DOMAIN_FILTER_FIELDS","DOMAIN_FILTER_HEADERS","reason","detail","DOMAIN_FILTER_OPTIONS","map","value","label","DOMAIN_SEARCH_FIELDS","addedOn","entry","addedAtMs","Intl","DateTimeFormat","dateStyle","format","domainColumns","busy","headerName","flex","minWidth","renderCell","row","variant","component","sx","fontWeight","size","color","width","added","aria-label","disabled","onClick","path","fontSize","OutreachDoNotContactDomainsCard","props","orgId","api","listed","enqueueSnackbar","draft","setDraft","setBusy","normalized","invalid","trim","gridFilter","selectFields","searchKey","searchWords","join","rows","data","$id","clauses","paths","words","change","action","answer","changeDoNotContactDomain","changed","error","message","allowDuplicate","header","help","anchor","contentGutterX","contentGutterY","spacing","direction","xs","sm","alignItems","onChange","event","target","onKeyDown","key","preventDefault","helperText","flexGrow","status","what","length","fields","headers","setClauses","options","columns","filterMode","filterModel","onFilterModelChange","quickFilter","noRowsLabel","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/do-not-contact-domains.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { pluginDocsHelp } from '@aglyn/aglyn'\nimport { mdiTrashCanOutline } from '@aglyn/shared-data-mdi'\nimport { CardDisplay, MdiIcon } from '@aglyn/shared-ui-jsx'\nimport ListFilterChips from '@aglyn/shared-ui-jsx/components/list-filter-chips.component'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport ListQueryNotices, {\n listQueryRefusals,\n} from '@aglyn/shared-ui-jsx/components/list-query-notices.component'\nimport {\n ListTable,\n listActionsColumn,\n type ListTableProps,\n} from '@aglyn/shared-ui-jsx/components/list-table.component'\nimport { listFilterGridColumns } from '@aglyn/shared-ui-jsx/const/list-grid-filter'\nimport { useListGridFilter } from '@aglyn/shared-ui-jsx/hooks/use-list-grid-filter'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Button, Chip, IconButton, Stack, TextField, Typography } from '@mui/material'\nimport { useState } from 'react'\nimport { normalizeOutreachDomain } from '../engine/do-not-contact-domain'\nimport { OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY } from '../model/do-not-contact-domain-list-query'\nimport {\n OUTREACH_DO_NOT_CONTACT_REASONS,\n type OutreachDoNotContactDomainEntry,\n type OutreachDoNotContactReason,\n} from '../model/outreach.types'\nimport { OutreachLoading, OutreachLoadProblem } from './outreach-ui'\nimport { useOutreachApi } from './use-outreach-api'\nimport { useOutreachDoNotContactDomainList } from './use-outreach-data'\n\nexport interface OutreachDoNotContactDomainsCardProps {\n /** The organization the hub is mounted under; `null` until the shell knows it. */\n orgId: string | null\n}\n\n/** Why a domain is on the list, as the card says it. */\nexport const OUTREACH_DO_NOT_CONTACT_REASON_LABELS: Record<OutreachDoNotContactReason, string> = {\n manual: 'Added by a member',\n opt_out_reply: 'A reply asked not to be emailed',\n unsubscribe: 'Unsubscribed',\n hard_bounce: 'Mail bounced',\n gateway_block: 'Its mail gateway blocked the sender',\n}\n\n/** Accessible names of the card's controls, spelled once for the specs. */\nexport const DO_NOT_CONTACT_DOMAIN_LABELS = {\n field: 'Domain',\n add: 'Add domain',\n remove: (domain: string) => `Remove ${domain}`,\n} as const\n\n/** What the field says under a value that is not a domain. */\nexport const DO_NOT_CONTACT_DOMAIN_HINT = 'A domain, such as example.com. Every address at it is refused.'\n\n/*\n * What the domains grid's Filters panel offers (AGL-3317), each field on the\n * list's Firestore query (AGL-3321, `OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY`):\n * the whole domain, why it is listed, and when it was added. The search box\n * reads the domain, each of its labels and the words of its detail.\n */\nconst DOMAIN_FILTER_FIELDS = OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY.fields\nconst DOMAIN_FILTER_HEADERS: Readonly<Record<string, string>> = {\n domain: 'Domain',\n reason: 'Why',\n addedAtMs: 'Added',\n}\nconst DOMAIN_FILTER_OPTIONS = {\n reason: OUTREACH_DO_NOT_CONTACT_REASONS.map((reason) => ({\n value: reason,\n label: OUTREACH_DO_NOT_CONTACT_REASON_LABELS[reason],\n })),\n}\n\nfunction addedOn(entry: OutreachDoNotContactDomainEntry): string {\n if (!entry.addedAtMs) return ''\n return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(entry.addedAtMs)\n}\n\n/** The grid's columns; the remove button names its domain for a screen reader. */\nfunction domainColumns(\n busy: boolean,\n remove: (domain: string) => void,\n): NonNullable<ListTableProps['columns']> {\n return listFilterGridColumns(\n [\n {\n field: 'domain',\n headerName: 'Domain',\n flex: 1,\n minWidth: 160,\n renderCell: ({ row }) => (\n <Typography variant=\"body2\" component=\"span\" sx={{ fontWeight: 500 }}>\n {row.domain}\n </Typography>\n ),\n },\n {\n field: 'reason',\n headerName: 'Why',\n flex: 1,\n minWidth: 200,\n renderCell: ({ row }) => (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n color={row.reason === 'gateway_block' ? 'warning' : 'default'}\n label={\n OUTREACH_DO_NOT_CONTACT_REASON_LABELS[row.reason as OutreachDoNotContactReason] ??\n row.reason\n }\n />\n ),\n },\n {\n field: 'addedAtMs',\n headerName: 'Added',\n width: 140,\n renderCell: ({ row }) => row.added,\n },\n { field: 'detail', headerName: 'Detail', flex: 1, minWidth: 160 },\n listActionsColumn(\n (row) => (\n <IconButton\n size=\"small\"\n aria-label={DO_NOT_CONTACT_DOMAIN_LABELS.remove(row.domain)}\n disabled={busy}\n onClick={() => remove(row.domain)}\n >\n <MdiIcon path={mdiTrashCanOutline.path} fontSize=\"small\" />\n </IconButton>\n ),\n { width: 72 },\n ),\n ],\n DOMAIN_FILTER_FIELDS,\n DOMAIN_FILTER_OPTIONS,\n DOMAIN_FILTER_HEADERS,\n )\n}\n\n/**\n * Sequences → Compliance → Do not contact domains (AGL-3244): the domains no\n * sequence emails anyone at, whoever enrolls them.\n *\n * A member adds one by hand — a company that asked, or one whose gateway is\n * known to block cold mail — and the sending runtime adds one when a hard\n * bounce reads as the domain's gateway refusing the sender. Either way it\n * is listed here with why, and a member takes it off by name.\n */\nexport function OutreachDoNotContactDomainsCard(props: OutreachDoNotContactDomainsCardProps) {\n const { orgId } = props\n const api = useOutreachApi(orgId)\n const { enqueueSnackbar } = useSnackbar()\n const [draft, setDraft] = useState('')\n const [busy, setBusy] = useState<string | null>(null)\n const normalized = normalizeOutreachDomain(draft)\n const invalid = draft.trim() !== '' && normalized === null\n const gridFilter = useListGridFilter({ selectFields: ['reason'] })\n const filtering =\n gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word) => word.trim())\n /*\n * One page of the domains, alphabetical, with every clause and the search\n * word on the query (AGL-3321) — never matched over the domains that\n * happen to be loaded.\n */\n const listed = useOutreachDoNotContactDomainList(orgId, {\n clauses: gridFilter.clauses,\n search: gridFilter.searchWords,\n })\n const rows = listed.rows.map((entry) => ({\n $id: entry.domain,\n domain: entry.domain,\n reason: entry.reason,\n added: addedOn(entry),\n addedAtMs: entry.addedAtMs ?? 0,\n detail: entry.detail ?? '',\n }))\n /** Nothing listed at all — not a filter that matched nothing. */\n const none =\n listed.status === 'ready' && !listed.rows.length && !filtering && listed.page === 0\n\n const change = async (action: 'add' | 'remove', domain: string) => {\n setBusy(domain)\n try {\n const answer = await api.changeDoNotContactDomain(action, domain)\n if (action === 'add') setDraft('')\n enqueueSnackbar(\n answer.changed\n ? action === 'add'\n ? `${answer.domain} is on the do-not-contact list.`\n : `${answer.domain} is off the do-not-contact list.`\n : action === 'add'\n ? `${answer.domain} was already on the list.`\n : `${answer.domain} was not on the list.`,\n { variant: answer.changed ? 'success' : 'info' },\n )\n } catch (error) {\n enqueueSnackbar((error as Error).message, { variant: 'error', allowDuplicate: true })\n } finally {\n setBusy(null)\n }\n }\n\n return (\n <CardDisplay\n header=\"Do not contact domains\"\n help={pluginDocsHelp('sequences', { anchor: '#do-not-contact-domains' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={2}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'No sequence emails anyone at these domains, whoever enrolls them. Add a company that asked not ' +\n 'to hear from you, or one whose mail gateway blocks you. When an email bounces because the ' +\n 'recipient’s gateway refused it — rather than because the address is unknown — the domain is ' +\n 'added here automatically, so the next person at that company is not tried.'}\n </Typography>\n <Stack direction={{ xs: 'column', sm: 'row' }} spacing={1} sx={{ alignItems: { sm: 'flex-start' } }}>\n <TextField\n label={DO_NOT_CONTACT_DOMAIN_LABELS.field}\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === 'Enter' && normalized && !busy) {\n event.preventDefault()\n void change('add', normalized)\n }\n }}\n error={invalid}\n helperText={invalid ? 'That is not a domain.' : DO_NOT_CONTACT_DOMAIN_HINT}\n size=\"small\"\n sx={{ flexGrow: 1 }}\n />\n <Button\n variant=\"outlined\"\n disabled={!normalized || busy !== null}\n onClick={() => normalized && void change('add', normalized)}\n >\n {DO_NOT_CONTACT_DOMAIN_LABELS.add}\n </Button>\n </Stack>\n {listed.status === 'loading' && !filtering ? (\n <OutreachLoading label=\"Loading domains…\" />\n ) : listed.status === 'error' || listed.status === 'refused' ? (\n <OutreachLoadProblem status={listed.status} what=\"the do-not-contact domains\" />\n ) : none ? (\n <Typography variant=\"body2\" color=\"text.secondary\">\n No domains yet.\n </Typography>\n ) : (\n <Stack spacing={1}>\n <ListFilterChips\n fields={DOMAIN_FILTER_FIELDS}\n headers={DOMAIN_FILTER_HEADERS}\n clauses={gridFilter.clauses}\n onChange={gridFilter.setClauses}\n options={DOMAIN_FILTER_OPTIONS}\n />\n <ListQueryNotices\n refused={listQueryRefusals(listed.plan.refused, {\n fields: DOMAIN_FILTER_FIELDS,\n headers: DOMAIN_FILTER_HEADERS,\n options: DOMAIN_FILTER_OPTIONS,\n })}\n notices={listed.plan.notices}\n />\n <ListTable\n aria-label=\"Do not contact domains\"\n columns={domainColumns(busy !== null, (domain) => void change('remove', domain))}\n rows={rows}\n loading={listed.status === 'loading'}\n /*\n * The panel and the search are the grid's; every clause and\n * the search word are on the list's query (AGL-3321). The grid\n * neither filters nor sorts the page it is handed: the query's\n * order — alphabetical, or newest added while a date applies —\n * is the list's.\n */\n filterMode=\"server\"\n filterModel={gridFilter.filterModel}\n onFilterModelChange={gridFilter.onFilterModelChange}\n quickFilter\n disableColumnSorting\n noRowsLabel=\"No domains match these filters\"\n hideFooter\n />\n <ListPagination\n page={listed.page}\n pageSize={listed.pageSize}\n rowCount={rows.length}\n hasMore={listed.hasMore}\n onPageChange={listed.setPage}\n onPageSizeChange={listed.setPageSize}\n />\n </Stack>\n )}\n </Stack>\n </CardDisplay>\n )\n}\nOutreachDoNotContactDomainsCard.displayName = 'OutreachDoNotContactDomainsCard'\n\nexport default OutreachDoNotContactDomainsCard\n"],"names":["pluginDocsHelp","mdiTrashCanOutline","CardDisplay","MdiIcon","ListFilterChips","ListPagination","ListQueryNotices","listQueryRefusals","ListTable","listActionsColumn","listFilterGridColumns","useListGridFilter","useSnackbar","Button","Chip","IconButton","Stack","TextField","Typography","useState","normalizeOutreachDomain","OUTREACH_DO_NOT_CONTACT_DOMAIN_LIST_QUERY","OUTREACH_DO_NOT_CONTACT_REASONS","OutreachLoading","OutreachLoadProblem","useOutreachApi","useOutreachDoNotContactDomainList","OUTREACH_DO_NOT_CONTACT_REASON_LABELS","manual","opt_out_reply","unsubscribe","hard_bounce","gateway_block","DO_NOT_CONTACT_DOMAIN_LABELS","field","add","remove","domain","DO_NOT_CONTACT_DOMAIN_HINT","DOMAIN_FILTER_FIELDS","fields","DOMAIN_FILTER_HEADERS","reason","addedAtMs","DOMAIN_FILTER_OPTIONS","map","value","label","addedOn","entry","Intl","DateTimeFormat","dateStyle","format","domainColumns","busy","headerName","flex","minWidth","renderCell","row","variant","component","sx","fontWeight","size","color","width","added","aria-label","disabled","onClick","path","fontSize","OutreachDoNotContactDomainsCard","props","orgId","api","enqueueSnackbar","draft","setDraft","setBusy","normalized","invalid","trim","gridFilter","selectFields","filtering","clauses","length","searchWords","some","word","listed","search","rows","$id","detail","none","status","page","change","action","answer","changeDoNotContactDomain","changed","error","message","allowDuplicate","header","help","anchor","contentGutterX","contentGutterY","spacing","direction","xs","sm","alignItems","onChange","event","target","onKeyDown","key","preventDefault","helperText","flexGrow","what","headers","setClauses","options","refused","plan","notices","columns","loading","filterMode","filterModel","onFilterModelChange","quickFilter","disableColumnSorting","noRowsLabel","hideFooter","pageSize","rowCount","hasMore","onPageChange","setPage","onPageSizeChange","setPageSize","displayName"],"mappings":"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"}
@@ -1,9 +1,11 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type OutreachEnrollment, type OutreachSequenceStep } from '../model/outreach.types';
3
3
  import type { OutreachApi } from './use-outreach-api';
4
- import type { OutreachEnrollmentsLoad } from './use-outreach-data';
5
4
  export interface OutreachEnrollmentsTableProps {
6
- enrollments: OutreachEnrollmentsLoad;
5
+ /** The organization, whose enrollments the table asks for. */
6
+ orgId: string;
7
+ /** The sequence whose people these are: every page is narrowed to it. */
8
+ sequenceId: string;
7
9
  steps: readonly OutreachSequenceStep[];
8
10
  /**
9
11
  * Whether this sequence counts link clicks (AGL-3239). The Clicks column
@@ -17,19 +17,21 @@
17
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
18
  import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.component";
19
19
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
20
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
20
21
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
21
22
  import { ListRowActions, ListTable, listActionsColumn } from "@aglyn/shared-ui-jsx/components/list-table.component";
22
23
  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";
24
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
25
25
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
26
26
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
27
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";
28
+ import { useState } from "react";
29
29
  import { OutreachCurateStepDialog, outreachNextEmailStepIndex } from "./curate-step-dialog.js";
30
30
  import { readOutreachEngagement } from "../model/enrollment-engagement.js";
31
- import { OUTREACH_ENROLLMENT_STATUSES, OUTREACH_TASK_KIND_LABELS } from "../model/outreach.types.js";
31
+ import { OUTREACH_ENROLLMENT_LIST_QUERY } from "../model/enrollment-list-query.js";
32
+ import { OUTREACH_ENROLLMENT_STATUSES, OUTREACH_STOP_REASONS, OUTREACH_TASK_KIND_LABELS } from "../model/outreach.types.js";
32
33
  import { formatOutreachTime, OUTREACH_ENROLLMENT_STATUS_LABELS, OUTREACH_STOP_REASON_LABELS, OutreachEnrollmentStatusChip, OutreachLoading, OutreachLoadProblem } from "./outreach-ui.js";
34
+ import { useOutreachEnrollmentList } from "./use-outreach-data.js";
33
35
  /** The step an enrollment is on, as a person reads it. */ export function outreachCurrentStepLabel(enrollment, steps) {
34
36
  if (enrollment.status === 'finished' || enrollment.stepIndex >= steps.length) return 'All steps done';
35
37
  const step = steps[enrollment.stepIndex];
@@ -84,23 +86,19 @@ const ACTION_LABELS = {
84
86
  }
85
87
  };
86
88
  /*
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
- ];
89
+ * What the enrollments grid's Filters panel offers (AGL-3317), each field on
90
+ * the table's Firestore query (AGL-3321, `OUTREACH_ENROLLMENT_LIST_QUERY`).
91
+ * Status, whether the person is a lead and the stop reason are picked; the
92
+ * address is typed whole. `target` and `email` are no column of their own,
93
+ * so they are hidden columns the panel can still reach; the search box
94
+ * reads the person's name and address.
95
+ */ const ENROLLMENT_FILTER_FIELDS = OUTREACH_ENROLLMENT_LIST_QUERY.fields;
98
96
  const ENROLLMENT_FILTER_HEADERS = {
99
97
  status: 'Status',
100
98
  target: 'Enrolled as',
101
- contactName: 'Name',
99
+ stopReason: 'Stop reason',
102
100
  email: 'Email',
103
- stopReason: 'Stop reason'
101
+ enrolled: 'Enrolled'
104
102
  };
105
103
  const ENROLLMENT_FILTER_OPTIONS = {
106
104
  status: OUTREACH_ENROLLMENT_STATUSES.map((status)=>({
@@ -116,14 +114,12 @@ const ENROLLMENT_FILTER_OPTIONS = {
116
114
  value: 'lead',
117
115
  label: 'Lead'
118
116
  }
119
- ]
117
+ ],
118
+ stopReason: OUTREACH_STOP_REASONS.map((reason)=>({
119
+ value: reason,
120
+ label: OUTREACH_STOP_REASON_LABELS[reason]
121
+ }))
120
122
  };
121
- /** What the quick search reads on an enrollment row. */ const ENROLLMENT_SEARCH_FIELDS = [
122
- 'contactName',
123
- 'email',
124
- 'step',
125
- 'stopReason'
126
- ];
127
123
  /** The filter-only columns never show. */ const ENROLLMENT_HIDDEN_COLUMNS = hiddenFilterVisibility(ENROLLMENT_FILTER_FIELDS, [
128
124
  'status',
129
125
  'stopReason'
@@ -276,7 +272,7 @@ const ENROLLMENT_FILTER_OPTIONS = {
276
272
  * do-not-contact. A table on wider screens, a list of cards on a phone.
277
273
  */ export function OutreachEnrollmentsTable(props) {
278
274
  var _CONFIRM_confirming_action, _CONFIRM_confirming_action1, _CONFIRM_confirming_action2;
279
- const { enrollments, steps, timeZone, api } = props;
275
+ const { steps, timeZone, api } = props;
280
276
  const trackClicks = props.trackClicks === true;
281
277
  const theme = useTheme();
282
278
  const narrow = useMediaQuery(theme.breakpoints.down('md'));
@@ -285,45 +281,23 @@ const ENROLLMENT_FILTER_OPTIONS = {
285
281
  const [detail, setDetail] = useState('');
286
282
  const [busy, setBusy] = useState(null);
287
283
  const [curating, setCurating] = useState(null);
288
- const [page, setPage] = useState(0);
289
- const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
290
284
  const gridFilter = useListGridFilter({
291
285
  selectFields: [
292
286
  'status',
293
- 'target'
287
+ 'target',
288
+ 'stopReason'
294
289
  ]
295
290
  });
296
- const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.length > 0;
291
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
297
292
  /*
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
326
- ]);
293
+ * One page of the sequence's enrollments, newest enrolled first, with
294
+ * every clause and the search word on the query (AGL-3321): a filter
295
+ * reaches the enrollment on page forty as surely as the one on page one,
296
+ * and nothing is matched over the rows that happen to be loaded.
297
+ */ const enrollments = useOutreachEnrollmentList(props.orgId, props.sequenceId, {
298
+ clauses: gridFilter.clauses,
299
+ search: gridFilter.searchWords
300
+ });
327
301
  const act = async (enrollment, action, why)=>{
328
302
  setBusy(enrollment.id);
329
303
  try {
@@ -354,16 +328,29 @@ const ENROLLMENT_FILTER_OPTIONS = {
354
328
  void act(enrollment, action);
355
329
  }
356
330
  };
357
- if (enrollments.status === 'loading') return /*#__PURE__*/ _jsx(OutreachLoading, {
331
+ const chips = /*#__PURE__*/ _jsx(ListFilterChips, {
332
+ fields: ENROLLMENT_FILTER_FIELDS,
333
+ headers: ENROLLMENT_FILTER_HEADERS,
334
+ clauses: gridFilter.clauses,
335
+ onChange: gridFilter.setClauses,
336
+ options: ENROLLMENT_FILTER_OPTIONS
337
+ });
338
+ if (enrollments.status === 'loading' && !filtering) return /*#__PURE__*/ _jsx(OutreachLoading, {
358
339
  label: "Loading enrollments…"
359
340
  });
360
341
  if (enrollments.status === 'error' || enrollments.status === 'refused') {
361
- return /*#__PURE__*/ _jsx(OutreachLoadProblem, {
362
- status: enrollments.status,
363
- what: "enrollments"
342
+ return /*#__PURE__*/ _jsxs(Stack, {
343
+ spacing: 1.5,
344
+ children: [
345
+ chips,
346
+ /*#__PURE__*/ _jsx(OutreachLoadProblem, {
347
+ status: enrollments.status,
348
+ what: "enrollments"
349
+ })
350
+ ]
364
351
  });
365
352
  }
366
- if (!enrollments.data.length) {
353
+ if (enrollments.status === 'ready' && !enrollments.rows.length && !filtering && enrollments.page === 0) {
367
354
  return /*#__PURE__*/ _jsx(Card, {
368
355
  variant: "outlined",
369
356
  children: /*#__PURE__*/ _jsx(EmptyStateComponent, {
@@ -412,28 +399,19 @@ const ENROLLMENT_FILTER_OPTIONS = {
412
399
  }) : null
413
400
  ]
414
401
  });
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
- };
402
+ const pageRows = enrollments.rows;
422
403
  return /*#__PURE__*/ _jsxs(Stack, {
423
404
  spacing: 1.5,
424
405
  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
406
+ chips,
407
+ /*#__PURE__*/ _jsx(ListQueryNotices, {
408
+ refused: listQueryRefusals(enrollments.plan.refused, {
409
+ fields: ENROLLMENT_FILTER_FIELDS,
410
+ headers: ENROLLMENT_FILTER_HEADERS,
411
+ options: ENROLLMENT_FILTER_OPTIONS
412
+ }),
413
+ notices: enrollments.plan.notices
431
414
  }),
432
- filtering && enrollments.hasMore ? /*#__PURE__*/ _jsx(Typography, {
433
- variant: "caption",
434
- color: "text.secondary",
435
- children: `Filtering the ${enrollments.data.length} enrollments read so far — the next page reads more.`
436
- }) : null,
437
415
  narrow ? /*#__PURE__*/ _jsx(Stack, {
438
416
  spacing: 1,
439
417
  component: "ul",
@@ -517,6 +495,7 @@ const ENROLLMENT_FILTER_OPTIONS = {
517
495
  columnVisibilityModel: ENROLLMENT_HIDDEN_COLUMNS
518
496
  }
519
497
  },
498
+ loading: enrollments.status === 'loading',
520
499
  rows: pageRows.map((enrollment)=>({
521
500
  $id: enrollment.id,
522
501
  enrollment,
@@ -530,23 +509,25 @@ const ENROLLMENT_FILTER_OPTIONS = {
530
509
  stopReason: outreachStopLabel(enrollment) || '—'
531
510
  })),
532
511
  /*
533
- * The panel and the search are the grid's; the table answers them
534
- * over the enrollments it read (AGL-3317).
512
+ * The panel and the search are the grid's; every clause and the
513
+ * search word are on the table's query (AGL-3321). The grid
514
+ * neither filters nor sorts the page it is handed: the query's
515
+ * one order, newest enrolled first, is the table's.
535
516
  */ filterMode: "server",
536
517
  filterModel: gridFilter.filterModel,
537
518
  onFilterModelChange: gridFilter.onFilterModelChange,
538
519
  quickFilter: true,
520
+ disableColumnSorting: true,
539
521
  noRowsLabel: "No enrollments match these filters",
540
522
  hideFooter: true
541
523
  }),
542
524
  /*#__PURE__*/ _jsx(ListPagination, {
543
- page: page,
544
- pageSize: pageSize,
525
+ page: enrollments.page,
526
+ pageSize: enrollments.pageSize,
545
527
  rowCount: pageRows.length,
546
- count: enrollments.hasMore ? undefined : loaded.length,
547
- hasMore: enrollments.hasMore || page < lastLoadedPage,
548
- onPageChange: turnTo,
549
- onPageSizeChange: setPageSize
528
+ hasMore: enrollments.hasMore,
529
+ onPageChange: enrollments.setPage,
530
+ onPageSizeChange: enrollments.setPageSize
550
531
  }),
551
532
  /*#__PURE__*/ _jsx(OutreachCurateStepDialog, {
552
533
  enrollment: curating,