@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
@@ -19,5 +19,22 @@ export interface OutreachSequenceReportCardProps {
19
19
  links: OutreachSequenceLinksLoad;
20
20
  /** The mailbox's zone, which times are read in; `null` for the reader's own. */
21
21
  timeZone: string | null;
22
+ /** Lists the people who clicked, on the Enrollments tab (AGL-3332). */
23
+ onShowClickers?: () => void;
24
+ /** Lists the people who followed one destination, on the Enrollments tab (AGL-3332). */
25
+ onShowLink?: (url: string) => void;
22
26
  }
27
+ /**
28
+ * One figure, with what it counts under it — and where it leads, when it
29
+ * leads somewhere. The sequence's Results and one person's page (AGL-3332)
30
+ * draw their numbers alike.
31
+ */
32
+ export declare function OutreachFigure(props: {
33
+ label: string;
34
+ value: string;
35
+ hint?: string;
36
+ /** Opens the list behind the figure; named for a screen reader by `action`. */
37
+ onClick?: () => void;
38
+ action?: string;
39
+ }): import("react").JSX.Element;
23
40
  export declare function OutreachSequenceReportCard(props: OutreachSequenceReportCardProps): import("react").JSX.Element;
@@ -15,24 +15,25 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
- import { Alert, Card, CardContent, Divider, Stack, TableBody, TableCell, TableHead, TableRow, Typography } from "@mui/material";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import { Alert, ButtonBase, Card, CardContent, Divider, Link, Stack, TableBody, TableCell, TableHead, TableRow, Tooltip, Typography } from "@mui/material";
20
20
  // Not a raw MUI `Table`: a destination is a long string, and a raw table is
21
21
  // cut off by the card around it with nothing able to scroll to what is past
22
22
  // the edge (AGL-3045).
23
23
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
24
24
  import { outreachSequenceLinkReport, outreachSequenceReport } from "../model/sequence-report.js";
25
25
  import { formatOutreachTime, OutreachLoadProblem } from "./outreach-ui.js";
26
- /** One figure, with what it counts under it. */ function Figure(props) {
27
- return /*#__PURE__*/ _jsxs(Stack, {
28
- spacing: 0,
29
- sx: {
30
- minWidth: 110
31
- },
26
+ /**
27
+ * One figure, with what it counts under it — and where it leads, when it
28
+ * leads somewhere. The sequence's Results and one person's page (AGL-3332)
29
+ * draw their numbers alike.
30
+ */ export function OutreachFigure(props) {
31
+ const body = /*#__PURE__*/ _jsxs(_Fragment, {
32
32
  children: [
33
33
  /*#__PURE__*/ _jsx(Typography, {
34
34
  variant: "h5",
35
35
  component: "p",
36
+ color: props.onClick ? 'primary' : undefined,
36
37
  children: props.value
37
38
  }),
38
39
  /*#__PURE__*/ _jsx(Typography, {
@@ -46,6 +47,36 @@ import { formatOutreachTime, OutreachLoadProblem } from "./outreach-ui.js";
46
47
  }) : null
47
48
  ]
48
49
  });
50
+ if (!props.onClick) {
51
+ return /*#__PURE__*/ _jsx(Stack, {
52
+ spacing: 0,
53
+ sx: {
54
+ minWidth: 110
55
+ },
56
+ children: body
57
+ });
58
+ }
59
+ return /*#__PURE__*/ _jsx(Tooltip, {
60
+ title: props.action,
61
+ children: /*#__PURE__*/ _jsx(ButtonBase, {
62
+ onClick: props.onClick,
63
+ "aria-label": `${props.value} ${props.label}. ${props.action}`,
64
+ sx: {
65
+ minWidth: 110,
66
+ display: 'flex',
67
+ flexDirection: 'column',
68
+ alignItems: 'flex-start',
69
+ textAlign: 'left',
70
+ borderRadius: 1,
71
+ px: 1,
72
+ mx: -1,
73
+ '&:hover, &.Mui-focusVisible': {
74
+ bgcolor: 'action.hover'
75
+ }
76
+ },
77
+ children: body
78
+ })
79
+ });
49
80
  }
50
81
  /** A whole number as the card prints one. */ const count = (value)=>value.toLocaleString();
51
82
  /**
@@ -86,27 +117,30 @@ export function OutreachSequenceReportCard(props) {
86
117
  rowGap: 2
87
118
  },
88
119
  children: [
89
- /*#__PURE__*/ _jsx(Figure, {
120
+ /*#__PURE__*/ _jsx(OutreachFigure, {
90
121
  label: "Emails sent",
91
122
  value: count(report.sent)
92
123
  }),
93
- /*#__PURE__*/ _jsx(Figure, _extends({
124
+ /*#__PURE__*/ _jsx(OutreachFigure, _extends({
94
125
  label: "People emailed",
95
126
  value: report.people === null ? '—' : count(report.people)
96
127
  }, report.people === null ? {
97
128
  hint: 'Not counted for this sequence'
98
129
  } : {})),
99
- /*#__PURE__*/ _jsx(Figure, {
130
+ /*#__PURE__*/ _jsx(OutreachFigure, _extends({
100
131
  label: "Clicked",
101
132
  value: count(report.uniqueClicks),
102
133
  hint: report.clicks === report.uniqueClicks ? undefined : `${count(report.clicks)} clicks in all`
103
- }),
104
- /*#__PURE__*/ _jsx(Figure, {
134
+ }, props.onShowClickers && report.uniqueClicks > 0 ? {
135
+ onClick: props.onShowClickers,
136
+ action: 'Show who clicked'
137
+ } : {})),
138
+ /*#__PURE__*/ _jsx(OutreachFigure, {
105
139
  label: "Click rate",
106
140
  value: clickRate.value,
107
141
  hint: clickRate.hint
108
142
  }),
109
- report.machineClicks > 0 ? /*#__PURE__*/ _jsx(Figure, {
143
+ report.machineClicks > 0 ? /*#__PURE__*/ _jsx(OutreachFigure, {
110
144
  label: "Scanner clicks",
111
145
  value: count(report.machineClicks),
112
146
  hint: "Not in the rate"
@@ -156,12 +190,25 @@ export function OutreachSequenceReportCard(props) {
156
190
  }),
157
191
  /*#__PURE__*/ _jsx(TableBody, {
158
192
  children: links.rows.map((row)=>/*#__PURE__*/ _jsxs(TableRow, {
193
+ hover: Boolean(props.onShowLink),
159
194
  children: [
160
195
  /*#__PURE__*/ _jsx(TableCell, {
161
196
  sx: {
162
197
  wordBreak: 'break-all'
163
198
  },
164
- children: row.url
199
+ children: props.onShowLink ? /*#__PURE__*/ _jsx(Link, {
200
+ component: "button",
201
+ type: "button",
202
+ variant: "body2",
203
+ underline: "hover",
204
+ onClick: ()=>props.onShowLink == null ? void 0 : props.onShowLink.call(props, row.url),
205
+ "aria-label": `Show who followed ${row.url}`,
206
+ sx: {
207
+ textAlign: 'left',
208
+ wordBreak: 'break-all'
209
+ },
210
+ children: row.url
211
+ }) : row.url
165
212
  }),
166
213
  /*#__PURE__*/ _jsx(TableCell, {
167
214
  align: "right",
@@ -176,6 +223,7 @@ export function OutreachSequenceReportCard(props) {
176
223
  variant: "caption",
177
224
  color: "text.secondary",
178
225
  children: [
226
+ props.onShowLink ? 'Select a destination to see who followed it. ' : '',
179
227
  "Two links to the same page are counted as one destination, whatever tracking parameters follow the address.",
180
228
  links.truncated ? ` ${count(links.overflowClicks)} more clicks went to destinations past the ones listed.` : ''
181
229
  ]
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-report-card.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 type { CampaignRate } from '@aglyn/shared-ui-email-campaigns/model'\nimport {\n Alert,\n Card,\n CardContent,\n Divider,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Typography,\n} from '@mui/material'\n// Not a raw MUI `Table`: a destination is a long string, and a raw table is\n// cut off by the card around it with nothing able to scroll to what is past\n// the edge (AGL-3045).\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport {\n outreachSequenceLinkReport,\n outreachSequenceReport,\n} from '../model/sequence-report'\nimport { formatOutreachTime, OutreachLoadProblem } from './outreach-ui'\nimport type { OutreachSequenceLinksLoad } from './use-outreach-data'\n\n/**\n * WHAT A SEQUENCE MEASURED (AGL-3239).\n *\n * Four figures and one rate, and the sentences saying what each of them is\n * not. The decisions are all in `model/sequence-report.ts`; this renders\n * them and adds no arithmetic of its own — in particular it never divides,\n * so there is no second place a percentage can be computed differently from\n * the way a campaign's report computes it.\n *\n * The caveats are rendered as prominently as the numbers rather than as\n * small print. \"Opens aren't measured\" is the thing a reader coming from\n * the campaign report most needs told, and a blank where an open rate would\n * be says the opposite of what is true.\n */\n\nexport interface OutreachSequenceReportCardProps {\n sequence: Pick<OutreachSequence, 'stats' | 'settings'>\n links: OutreachSequenceLinksLoad\n /** The mailbox's zone, which times are read in; `null` for the reader's own. */\n timeZone: string | null\n}\n\n/** One figure, with what it counts under it. */\nfunction Figure(props: { label: string; value: string; hint?: string }) {\n return (\n <Stack spacing={0} sx={{ minWidth: 110 }}>\n <Typography variant=\"h5\" component=\"p\">\n {props.value}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n {props.hint ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.hint}\n </Typography>\n ) : null}\n </Stack>\n )\n}\n\n/** A whole number as the card prints one. */\nconst count = (value: number): string => value.toLocaleString()\n\n/**\n * A rate and the denominator it was taken over, or the em dash that stands\n * for a rate the model refused to take. The denominator is printed beside\n * the percentage, never dropped: \"12% of people emailed\" and \"12% of people\n * who opened\" are different claims and a bare \"12%\" is neither.\n */\nfunction rateText(rate: CampaignRate | null): { value: string; hint?: string } {\n if (!rate) return { value: '—' }\n return {\n value: `${(rate.value * 100).toFixed(1)}%`,\n hint: `${count(rate.numerator)} of ${count(rate.denominator)} ${rate.denominatorLabel}`,\n }\n}\n\nexport function OutreachSequenceReportCard(props: OutreachSequenceReportCardProps) {\n const report = outreachSequenceReport(\n props.sequence.stats,\n props.sequence.settings.trackClicks,\n )\n const clickRate = rateText(report.rates.click)\n const links = outreachSequenceLinkReport(\n props.links.status === 'ready' ? (props.links.data ?? undefined) : undefined,\n )\n\n return (\n <Card variant=\"outlined\">\n <CardContent>\n <Stack spacing={2}>\n <Typography variant=\"h6\" component=\"h2\">\n Results\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <Figure label=\"Emails sent\" value={count(report.sent)} />\n <Figure\n label=\"People emailed\"\n value={report.people === null ? '—' : count(report.people)}\n {...(report.people === null ? { hint: 'Not counted for this sequence' } : {})}\n />\n <Figure\n label=\"Clicked\"\n value={count(report.uniqueClicks)}\n hint={\n report.clicks === report.uniqueClicks\n ? undefined\n : `${count(report.clicks)} clicks in all`\n }\n />\n <Figure label=\"Click rate\" value={clickRate.value} hint={clickRate.hint} />\n {report.machineClicks > 0 ? (\n <Figure\n label=\"Scanner clicks\"\n value={count(report.machineClicks)}\n hint=\"Not in the rate\"\n />\n ) : null}\n </Stack>\n {report.lastClickAtMs === null ? null : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n Last click {formatOutreachTime(report.lastClickAtMs, props.timeZone)}\n </Typography>\n )}\n\n {report.caveats.map((entry) => (\n <Alert key={entry.id} severity=\"info\" variant=\"outlined\">\n {entry.message}\n </Alert>\n ))}\n\n {props.links.status === 'error' || props.links.status === 'refused' ? (\n <OutreachLoadProblem status={props.links.status} what=\"the link report\" />\n ) : links.rows.length ? (\n <Stack spacing={1}>\n <Divider />\n <Typography variant=\"subtitle2\" component=\"h3\">\n Links followed\n </Typography>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>Destination</TableCell>\n <TableCell align=\"right\">Clicks</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {links.rows.map((row) => (\n <TableRow key={row.url}>\n <TableCell sx={{ wordBreak: 'break-all' }}>{row.url}</TableCell>\n <TableCell align=\"right\">{count(row.clicks)}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n Two links to the same page are counted as one destination, whatever\n tracking parameters follow the address.\n {links.truncated\n ? ` ${count(links.overflowClicks)} more clicks went to destinations past the ones listed.`\n : ''}\n </Typography>\n </Stack>\n ) : null}\n </Stack>\n </CardContent>\n </Card>\n )\n}\n"],"names":["Alert","Card","CardContent","Divider","Stack","TableBody","TableCell","TableHead","TableRow","Typography","ScrollTable","outreachSequenceLinkReport","outreachSequenceReport","formatOutreachTime","OutreachLoadProblem","Figure","props","spacing","sx","minWidth","variant","component","value","label","hint","color","count","toLocaleString","rateText","rate","toFixed","numerator","denominator","denominatorLabel","OutreachSequenceReportCard","report","sequence","stats","settings","trackClicks","clickRate","rates","click","links","status","data","undefined","direction","flexWrap","rowGap","sent","people","uniqueClicks","clicks","machineClicks","lastClickAtMs","timeZone","caveats","map","entry","severity","message","id","what","rows","length","size","align","row","wordBreak","url","truncated","overflowClicks"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/components/sequence-report-card.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 type { CampaignRate } from '@aglyn/shared-ui-email-campaigns/model'\nimport {\n Alert,\n ButtonBase,\n Card,\n CardContent,\n Divider,\n Link,\n Stack,\n TableBody,\n TableCell,\n TableHead,\n TableRow,\n Tooltip,\n Typography,\n} from '@mui/material'\nimport type { ReactNode } from 'react'\n// Not a raw MUI `Table`: a destination is a long string, and a raw table is\n// cut off by the card around it with nothing able to scroll to what is past\n// the edge (AGL-3045).\nimport { ScrollTable } from '@aglyn/shared-ui-jsx/components/scroll-table.component'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport {\n outreachSequenceLinkReport,\n outreachSequenceReport,\n} from '../model/sequence-report'\nimport { formatOutreachTime, OutreachLoadProblem } from './outreach-ui'\nimport type { OutreachSequenceLinksLoad } from './use-outreach-data'\n\n/**\n * WHAT A SEQUENCE MEASURED (AGL-3239).\n *\n * Four figures and one rate, and the sentences saying what each of them is\n * not. The decisions are all in `model/sequence-report.ts`; this renders\n * them and adds no arithmetic of its own — in particular it never divides,\n * so there is no second place a percentage can be computed differently from\n * the way a campaign's report computes it.\n *\n * The caveats are rendered as prominently as the numbers rather than as\n * small print. \"Opens aren't measured\" is the thing a reader coming from\n * the campaign report most needs told, and a blank where an open rate would\n * be says the opposite of what is true.\n */\n\nexport interface OutreachSequenceReportCardProps {\n sequence: Pick<OutreachSequence, 'stats' | 'settings'>\n links: OutreachSequenceLinksLoad\n /** The mailbox's zone, which times are read in; `null` for the reader's own. */\n timeZone: string | null\n /** Lists the people who clicked, on the Enrollments tab (AGL-3332). */\n onShowClickers?: () => void\n /** Lists the people who followed one destination, on the Enrollments tab (AGL-3332). */\n onShowLink?: (url: string) => void\n}\n\n/**\n * One figure, with what it counts under it — and where it leads, when it\n * leads somewhere. The sequence's Results and one person's page (AGL-3332)\n * draw their numbers alike.\n */\nexport function OutreachFigure(props: {\n label: string\n value: string\n hint?: string\n /** Opens the list behind the figure; named for a screen reader by `action`. */\n onClick?: () => void\n action?: string\n}) {\n const body: ReactNode = (\n <>\n <Typography variant=\"h5\" component=\"p\" color={props.onClick ? 'primary' : undefined}>\n {props.value}\n </Typography>\n <Typography variant=\"body2\">{props.label}</Typography>\n {props.hint ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.hint}\n </Typography>\n ) : null}\n </>\n )\n if (!props.onClick) {\n return (\n <Stack spacing={0} sx={{ minWidth: 110 }}>\n {body}\n </Stack>\n )\n }\n return (\n <Tooltip title={props.action}>\n <ButtonBase\n onClick={props.onClick}\n aria-label={`${props.value} ${props.label}. ${props.action}`}\n sx={{\n minWidth: 110,\n display: 'flex',\n flexDirection: 'column',\n alignItems: 'flex-start',\n textAlign: 'left',\n borderRadius: 1,\n px: 1,\n mx: -1,\n '&:hover, &.Mui-focusVisible': { bgcolor: 'action.hover' },\n }}\n >\n {body}\n </ButtonBase>\n </Tooltip>\n )\n}\n\n/** A whole number as the card prints one. */\nconst count = (value: number): string => value.toLocaleString()\n\n/**\n * A rate and the denominator it was taken over, or the em dash that stands\n * for a rate the model refused to take. The denominator is printed beside\n * the percentage, never dropped: \"12% of people emailed\" and \"12% of people\n * who opened\" are different claims and a bare \"12%\" is neither.\n */\nfunction rateText(rate: CampaignRate | null): { value: string; hint?: string } {\n if (!rate) return { value: '—' }\n return {\n value: `${(rate.value * 100).toFixed(1)}%`,\n hint: `${count(rate.numerator)} of ${count(rate.denominator)} ${rate.denominatorLabel}`,\n }\n}\n\nexport function OutreachSequenceReportCard(props: OutreachSequenceReportCardProps) {\n const report = outreachSequenceReport(\n props.sequence.stats,\n props.sequence.settings.trackClicks,\n )\n const clickRate = rateText(report.rates.click)\n const links = outreachSequenceLinkReport(\n props.links.status === 'ready' ? (props.links.data ?? undefined) : undefined,\n )\n\n return (\n <Card variant=\"outlined\">\n <CardContent>\n <Stack spacing={2}>\n <Typography variant=\"h6\" component=\"h2\">\n Results\n </Typography>\n <Stack direction=\"row\" spacing={3} sx={{ flexWrap: 'wrap', rowGap: 2 }}>\n <OutreachFigure label=\"Emails sent\" value={count(report.sent)} />\n <OutreachFigure\n label=\"People emailed\"\n value={report.people === null ? '—' : count(report.people)}\n {...(report.people === null ? { hint: 'Not counted for this sequence' } : {})}\n />\n <OutreachFigure\n label=\"Clicked\"\n value={count(report.uniqueClicks)}\n hint={\n report.clicks === report.uniqueClicks\n ? undefined\n : `${count(report.clicks)} clicks in all`\n }\n {...(props.onShowClickers && report.uniqueClicks > 0\n ? { onClick: props.onShowClickers, action: 'Show who clicked' }\n : {})}\n />\n <OutreachFigure label=\"Click rate\" value={clickRate.value} hint={clickRate.hint} />\n {report.machineClicks > 0 ? (\n <OutreachFigure\n label=\"Scanner clicks\"\n value={count(report.machineClicks)}\n hint=\"Not in the rate\"\n />\n ) : null}\n </Stack>\n {report.lastClickAtMs === null ? null : (\n <Typography variant=\"body2\" color=\"text.secondary\">\n Last click {formatOutreachTime(report.lastClickAtMs, props.timeZone)}\n </Typography>\n )}\n\n {report.caveats.map((entry) => (\n <Alert key={entry.id} severity=\"info\" variant=\"outlined\">\n {entry.message}\n </Alert>\n ))}\n\n {props.links.status === 'error' || props.links.status === 'refused' ? (\n <OutreachLoadProblem status={props.links.status} what=\"the link report\" />\n ) : links.rows.length ? (\n <Stack spacing={1}>\n <Divider />\n <Typography variant=\"subtitle2\" component=\"h3\">\n Links followed\n </Typography>\n <ScrollTable size=\"small\">\n <TableHead>\n <TableRow>\n <TableCell>Destination</TableCell>\n <TableCell align=\"right\">Clicks</TableCell>\n </TableRow>\n </TableHead>\n <TableBody>\n {links.rows.map((row) => (\n <TableRow key={row.url} hover={Boolean(props.onShowLink)}>\n <TableCell sx={{ wordBreak: 'break-all' }}>\n {props.onShowLink ? (\n <Link\n component=\"button\"\n type=\"button\"\n variant=\"body2\"\n underline=\"hover\"\n onClick={() => props.onShowLink?.(row.url)}\n aria-label={`Show who followed ${row.url}`}\n sx={{ textAlign: 'left', wordBreak: 'break-all' }}\n >\n {row.url}\n </Link>\n ) : (\n row.url\n )}\n </TableCell>\n <TableCell align=\"right\">{count(row.clicks)}</TableCell>\n </TableRow>\n ))}\n </TableBody>\n </ScrollTable>\n <Typography variant=\"caption\" color=\"text.secondary\">\n {props.onShowLink ? 'Select a destination to see who followed it. ' : ''}\n Two links to the same page are counted as one destination, whatever\n tracking parameters follow the address.\n {links.truncated\n ? ` ${count(links.overflowClicks)} more clicks went to destinations past the ones listed.`\n : ''}\n </Typography>\n </Stack>\n ) : null}\n </Stack>\n </CardContent>\n </Card>\n )\n}\n"],"names":["Alert","ButtonBase","Card","CardContent","Divider","Link","Stack","TableBody","TableCell","TableHead","TableRow","Tooltip","Typography","ScrollTable","outreachSequenceLinkReport","outreachSequenceReport","formatOutreachTime","OutreachLoadProblem","OutreachFigure","props","body","variant","component","color","onClick","undefined","value","label","hint","spacing","sx","minWidth","title","action","aria-label","display","flexDirection","alignItems","textAlign","borderRadius","px","mx","bgcolor","count","toLocaleString","rateText","rate","toFixed","numerator","denominator","denominatorLabel","OutreachSequenceReportCard","report","sequence","stats","settings","trackClicks","clickRate","rates","click","links","status","data","direction","flexWrap","rowGap","sent","people","uniqueClicks","clicks","onShowClickers","machineClicks","lastClickAtMs","timeZone","caveats","map","entry","severity","message","id","what","rows","length","size","align","row","hover","Boolean","onShowLink","wordBreak","type","underline","url","truncated","overflowClicks"],"mappings":"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"}
@@ -7,7 +7,10 @@ export interface OutreachSequencesSectionProps {
7
7
  org?: Record<string, unknown> | null;
8
8
  /** The section's own path: `/[orgSlug]/outreach/sequences`. */
9
9
  sectionPath: string;
10
- /** The path below the section: `[]`, `['new']`, `[id]` or `[id, 'enrollments']`. */
10
+ /**
11
+ * The path below the section: `[]`, `['new']`, `[id]`, `[id, 'enrollments']`
12
+ * or `[id, 'enrollments', enrollmentId]` — one person's page (AGL-3332).
13
+ */
11
14
  subpath: readonly string[];
12
15
  /** The Mailboxes section: `/[orgSlug]/outreach/mailboxes`. */
13
16
  mailboxesPath: string;
@@ -28,6 +31,15 @@ export declare namespace OutreachSequencesSection {
28
31
  /**
29
32
  * Every sequence: its mailbox, its status, and how many people it enrolled
30
33
  * and where they stand — a table on wider screens, cards on a phone.
34
+ *
35
+ * ## Asked of Firestore, a page at a time
36
+ *
37
+ * The Filters panel and the search box are the grid's; the clauses and the
38
+ * words go onto ONE query over the organization's sequences, newest first,
39
+ * and each page is a page of that query's matches (AGL-3321). A clause the
40
+ * query cannot hold beside the others is not applied, and the notice above
41
+ * the list says which and why — never answered over the sequences that
42
+ * happen to be loaded.
31
43
  */
32
44
  export declare function OutreachSequenceList(props: {
33
45
  orgId: string;
@@ -23,17 +23,20 @@ import EmptyStateComponent from "@aglyn/shared-ui-jsx/components/empty-state.com
23
23
  import ListFilterChips from "@aglyn/shared-ui-jsx/components/list-filter-chips.component";
24
24
  import { ListPagination } from "@aglyn/shared-ui-jsx/components/list-pagination.component";
25
25
  import { ListTable } from "@aglyn/shared-ui-jsx/components/list-table.component";
26
- import { filterListRows, inMemoryListField, listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
27
- import { TABLE_PAGE_SIZE_DEFAULT } from "@aglyn/shared-ui-jsx/const/table-pagination";
26
+ import ListQueryNotices, { listQueryRefusals } from "@aglyn/shared-ui-jsx/components/list-query-notices.component";
27
+ import { hiddenFilterVisibility } from "@aglyn/shared-ui-jsx/const/list-filter";
28
+ import { listFilterGridColumns } from "@aglyn/shared-ui-jsx/const/list-grid-filter";
28
29
  import { useListGridFilter } from "@aglyn/shared-ui-jsx/hooks/use-list-grid-filter";
29
- import { Alert, Button, Card, CardContent, Chip, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
30
- import { useEffect, useMemo, useState } from "react";
30
+ import { Button, Card, CardContent, Chip, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
31
+ import { useMemo } from "react";
31
32
  import { OUTREACH_SEQUENCE_STATUSES } from "../model/outreach.types.js";
33
+ import { OUTREACH_SEQUENCE_LIST_QUERY } from "../model/sequence-list-query.js";
32
34
  import { OUTREACH_SEQUENCE_STATUS_LABELS, OutreachLink, OutreachLoading, OutreachLoadProblem, OutreachSequenceStatusChip, useOutreachNavigate } from "./outreach-ui.js";
35
+ import { OutreachEnrollmentDetail } from "./enrollment-detail.js";
33
36
  import { OutreachSequenceDetail } from "./sequence-detail.js";
34
37
  import { OutreachSequenceEditor } from "./sequence-editor.js";
35
38
  import { useOutreachApi } from "./use-outreach-api.js";
36
- import { useOutreachSequenceCounts, useOutreachSequences } from "./use-outreach-data.js";
39
+ import { useOutreachSequenceCounts, useOutreachSequenceList } from "./use-outreach-data.js";
37
40
  import { useOutreachMailboxes } from "./use-outreach-mailboxes.js";
38
41
  import { useOutreachComplianceSettings } from "./use-outreach-settings.js";
39
42
  /** The new-sequence page's segment below the section. */ export const OUTREACH_NEW_SEQUENCE_SEGMENT = 'new';
@@ -50,7 +53,7 @@ import { useOutreachComplianceSettings } from "./use-outreach-settings.js";
50
53
  if (!orgId) return /*#__PURE__*/ _jsx(OutreachLoading, {
51
54
  label: "Loading sequences…"
52
55
  });
53
- const [first, second] = subpath;
56
+ const [first, second, third] = subpath;
54
57
  if (first === OUTREACH_NEW_SEQUENCE_SEGMENT) {
55
58
  return /*#__PURE__*/ _jsxs(Stack, {
56
59
  spacing: 2,
@@ -77,6 +80,16 @@ import { useOutreachComplianceSettings } from "./use-outreach-settings.js";
77
80
  ]
78
81
  });
79
82
  }
83
+ if (first && second === 'enrollments' && third) {
84
+ return /*#__PURE__*/ _jsx(OutreachEnrollmentDetail, {
85
+ orgId: orgId,
86
+ orgMount: props.orgMount,
87
+ sectionPath: sectionPath,
88
+ sequenceId: first,
89
+ enrollmentId: third,
90
+ mailboxes: mailboxes
91
+ });
92
+ }
80
93
  if (first) {
81
94
  return /*#__PURE__*/ _jsx(OutreachSequenceDetail, {
82
95
  orgId: orgId,
@@ -121,85 +134,76 @@ const COUNT_COLUMNS = [
121
134
  ]
122
135
  ];
123
136
  /*
124
- * What the sequences grid's Filters panel offers (AGL-3317). The list holds
125
- * every sequence it read, so each clause and the quick search are answered
126
- * over that whole set before it is paged, never over the page on screen.
127
- */ const SEQUENCE_FILTER_FIELDS = [
128
- inMemoryListField('name', 'text'),
129
- inMemoryListField('mailbox', 'text'),
130
- inMemoryListField('status', 'select')
131
- ];
137
+ * What the sequences grid's Filters panel offers (AGL-3317), each field on
138
+ * the list's Firestore query (AGL-3321, `OUTREACH_SEQUENCE_LIST_QUERY`).
139
+ */ const SEQUENCE_FILTER_FIELDS = OUTREACH_SEQUENCE_LIST_QUERY.fields;
132
140
  const SEQUENCE_FILTER_HEADERS = {
133
141
  name: 'Name',
134
142
  mailbox: 'Mailbox',
135
- status: 'Status'
143
+ status: 'Status',
144
+ created: 'Created'
136
145
  };
137
- const SEQUENCE_FILTER_OPTIONS = {
138
- status: OUTREACH_SEQUENCE_STATUSES.map((status)=>({
139
- value: status,
140
- label: OUTREACH_SEQUENCE_STATUS_LABELS[status]
141
- }))
142
- };
143
- /** What the quick search reads on a sequence row. */ const SEQUENCE_SEARCH_FIELDS = [
146
+ /** `created` is no column of its own: a filter-only column, never shown. */ const SEQUENCE_HIDDEN_COLUMNS = hiddenFilterVisibility(SEQUENCE_FILTER_FIELDS, [
144
147
  'name',
145
- 'mailbox'
146
- ];
148
+ 'mailbox',
149
+ 'status'
150
+ ]);
151
+ const SEQUENCE_STATUS_OPTIONS = OUTREACH_SEQUENCE_STATUSES.map((status)=>({
152
+ value: status,
153
+ label: OUTREACH_SEQUENCE_STATUS_LABELS[status]
154
+ }));
155
+ /** How a mailbox reads in the list: the address it sends as. */ const mailboxName = (mailbox)=>mailbox.sendAs || mailbox.email;
147
156
  /**
148
157
  * Every sequence: its mailbox, its status, and how many people it enrolled
149
158
  * and where they stand — a table on wider screens, cards on a phone.
159
+ *
160
+ * ## Asked of Firestore, a page at a time
161
+ *
162
+ * The Filters panel and the search box are the grid's; the clauses and the
163
+ * words go onto ONE query over the organization's sequences, newest first,
164
+ * and each page is a page of that query's matches (AGL-3321). A clause the
165
+ * query cannot hold beside the others is not applied, and the notice above
166
+ * the list says which and why — never answered over the sequences that
167
+ * happen to be loaded.
150
168
  */ export function OutreachSequenceList(props) {
151
169
  const { orgId, sectionPath } = props;
152
170
  const theme = useTheme();
153
171
  const narrow = useMediaQuery(theme.breakpoints.down('md'));
154
172
  const navigate = useOutreachNavigate();
155
- const sequences = useOutreachSequences(orgId);
156
- const [page, setPage] = useState(0);
157
- const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT);
158
173
  const gridFilter = useListGridFilter({
159
174
  selectFields: [
160
- 'status'
175
+ 'status',
176
+ 'mailbox'
161
177
  ]
162
178
  });
179
+ const sequences = useOutreachSequenceList(orgId, {
180
+ clauses: gridFilter.clauses,
181
+ search: gridFilter.searchWords
182
+ });
183
+ const filtering = gridFilter.clauses.length > 0 || gridFilter.searchWords.some((word)=>word.trim());
184
+ const filterOptions = useMemo(()=>({
185
+ status: SEQUENCE_STATUS_OPTIONS,
186
+ mailbox: props.mailboxes.map((mailbox)=>({
187
+ value: mailbox.id,
188
+ label: mailboxName(mailbox)
189
+ }))
190
+ }), [
191
+ props.mailboxes
192
+ ]);
163
193
  const mailboxLabel = (sequence)=>{
164
194
  const mailbox = props.mailboxes.find((entry)=>entry.id === sequence.mailboxId);
165
- return mailbox ? mailbox.sendAs || mailbox.email : sequence.mailboxId ? 'Mailbox removed' : 'No mailbox yet';
195
+ return mailbox ? mailboxName(mailbox) : sequence.mailboxId ? 'Mailbox removed' : 'No mailbox yet';
166
196
  };
167
- /*
168
- * Filtered BEFORE the page is cut: the list read every sequence (under
169
- * `OUTREACH_SEQUENCES_LIMIT`), so a clause or a search word answers over
170
- * all of them, and the footer counts the matches.
171
- */ const searchKey = gridFilter.searchWords.join(' ');
172
- const matching = useMemo(()=>filterListRows(sequences.data.map((sequence)=>({
173
- sequence,
174
- name: sequence.name || 'Untitled',
175
- mailbox: mailboxLabel(sequence),
176
- status: sequence.status
177
- })), SEQUENCE_FILTER_FIELDS, gridFilter.clauses, {
178
- paths: SEQUENCE_SEARCH_FIELDS,
179
- words: gridFilter.searchWords
180
- }).map((row)=>row.sequence), // `searchKey` stands for the words, which are a new array each render.
181
- // eslint-disable-next-line react-hooks/exhaustive-deps
182
- [
183
- sequences.data,
184
- props.mailboxes,
185
- gridFilter.clauses,
186
- searchKey
187
- ]);
188
- // A narrowed list starts again at its first page.
189
- useEffect(()=>setPage(0), [
190
- gridFilter.clauses,
191
- searchKey
192
- ]);
193
- const pageRows = matching.slice(page * pageSize, (page + 1) * pageSize);
197
+ const pageRows = sequences.rows;
194
198
  // Counted for the page on screen only: five aggregations a sequence.
195
199
  const counts = useOutreachSequenceCounts(orgId, pageRows.map((sequence)=>sequence.id));
196
200
  const footer = /*#__PURE__*/ _jsx(ListPagination, {
197
- page: page,
198
- pageSize: pageSize,
201
+ page: sequences.page,
202
+ pageSize: sequences.pageSize,
199
203
  rowCount: pageRows.length,
200
- count: matching.length,
201
- onPageChange: setPage,
202
- onPageSizeChange: setPageSize
204
+ hasMore: sequences.hasMore,
205
+ onPageChange: sequences.setPage,
206
+ onPageSizeChange: sequences.setPageSize
203
207
  });
204
208
  const newHref = `${sectionPath}/${OUTREACH_NEW_SEQUENCE_SEGMENT}`;
205
209
  const newButton = /*#__PURE__*/ _jsx(Button, {
@@ -216,30 +220,38 @@ const SEQUENCE_FILTER_OPTIONS = {
216
220
  children: "New sequence"
217
221
  });
218
222
  const count = (sequence, key)=>counts[sequence.id] ? String(counts[sequence.id][key]) : '—';
219
- /*
220
- * The read stops at `OUTREACH_SEQUENCES_LIMIT`, newest first, and probes one
221
- * past it, so this shows only when older sequences really exist.
222
- */ const truncatedNotice = sequences.truncated ? /*#__PURE__*/ _jsx(Alert, {
223
- severity: "info",
224
- children: `Showing the ${sequences.data.length.toLocaleString()} newest sequences. ` + 'The filters and the search narrow these only; older sequences are ' + 'not listed here.'
225
- }) : null;
226
223
  const chips = /*#__PURE__*/ _jsx(ListFilterChips, {
227
224
  fields: SEQUENCE_FILTER_FIELDS,
228
225
  headers: SEQUENCE_FILTER_HEADERS,
229
226
  clauses: gridFilter.clauses,
230
227
  onChange: gridFilter.setClauses,
231
- options: SEQUENCE_FILTER_OPTIONS
228
+ options: filterOptions
232
229
  });
230
+ const notices = /*#__PURE__*/ _jsx(ListQueryNotices, {
231
+ refused: listQueryRefusals(sequences.plan.refused, {
232
+ fields: SEQUENCE_FILTER_FIELDS,
233
+ headers: SEQUENCE_FILTER_HEADERS,
234
+ options: filterOptions
235
+ }),
236
+ notices: sequences.plan.notices
237
+ });
238
+ /** Nothing stored at all — not a filter that matched nothing. */ const none = sequences.status === 'ready' && !pageRows.length && !filtering && sequences.page === 0;
233
239
  let body;
234
- if (sequences.status === 'loading') body = /*#__PURE__*/ _jsx(OutreachLoading, {
240
+ if (sequences.status === 'loading' && !filtering) body = /*#__PURE__*/ _jsx(OutreachLoading, {
235
241
  label: "Loading sequences…"
236
242
  });
237
243
  else if (sequences.status === 'error' || sequences.status === 'refused') {
238
- body = /*#__PURE__*/ _jsx(OutreachLoadProblem, {
239
- status: sequences.status,
240
- what: "sequences"
244
+ body = /*#__PURE__*/ _jsxs(Stack, {
245
+ spacing: 1,
246
+ children: [
247
+ chips,
248
+ /*#__PURE__*/ _jsx(OutreachLoadProblem, {
249
+ status: sequences.status,
250
+ what: "sequences"
251
+ })
252
+ ]
241
253
  });
242
- } else if (!sequences.data.length) {
254
+ } else if (none) {
243
255
  body = /*#__PURE__*/ _jsx(Card, {
244
256
  variant: "outlined",
245
257
  children: /*#__PURE__*/ _jsx(EmptyStateComponent, {
@@ -253,7 +265,8 @@ const SEQUENCE_FILTER_OPTIONS = {
253
265
  spacing: 1,
254
266
  children: [
255
267
  chips,
256
- /*#__PURE__*/ _jsx(Stack, {
268
+ notices,
269
+ pageRows.length ? /*#__PURE__*/ _jsx(Stack, {
257
270
  spacing: 1,
258
271
  component: "ul",
259
272
  sx: {
@@ -308,9 +321,12 @@ const SEQUENCE_FILTER_OPTIONS = {
308
321
  })
309
322
  })
310
323
  }, sequence.id))
324
+ }) : /*#__PURE__*/ _jsx(Typography, {
325
+ variant: "body2",
326
+ color: "text.secondary",
327
+ children: sequences.status === 'loading' ? 'Loading sequences…' : 'No sequences match these filters'
311
328
  }),
312
- footer,
313
- truncatedNotice
329
+ footer
314
330
  ]
315
331
  });
316
332
  } else {
@@ -342,14 +358,20 @@ const SEQUENCE_FILTER_OPTIONS = {
342
358
  align: 'right',
343
359
  headerAlign: 'right'
344
360
  }))
345
- ], SEQUENCE_FILTER_FIELDS, SEQUENCE_FILTER_OPTIONS, SEQUENCE_FILTER_HEADERS);
361
+ ], SEQUENCE_FILTER_FIELDS, filterOptions, SEQUENCE_FILTER_HEADERS);
346
362
  body = /*#__PURE__*/ _jsxs(Stack, {
347
363
  spacing: 1,
348
364
  children: [
349
365
  chips,
366
+ notices,
350
367
  /*#__PURE__*/ _jsx(ListTable, {
351
368
  "aria-label": "Sequences",
352
369
  columns: columns,
370
+ initialState: {
371
+ columns: {
372
+ columnVisibilityModel: SEQUENCE_HIDDEN_COLUMNS
373
+ }
374
+ },
353
375
  rows: pageRows.map((sequence)=>_extends({
354
376
  $id: sequence.id,
355
377
  name: sequence.name || 'Untitled',
@@ -360,18 +382,21 @@ const SEQUENCE_FILTER_OPTIONS = {
360
382
  count(sequence, key)
361
383
  ])))),
362
384
  onOpen: (id)=>navigate(`${sectionPath}/${id}`),
385
+ loading: sequences.status === 'loading',
363
386
  /*
364
- * The panel and the search are the grid's; the list answers them
365
- * over every sequence it read (AGL-3317).
387
+ * The panel and the search are the grid's; every clause and the
388
+ * search word are on the list's query (AGL-3321), and the grid
389
+ * neither filters nor sorts the page it is handed: the query's
390
+ * one order, newest first, is the list's.
366
391
  */ filterMode: "server",
367
392
  filterModel: gridFilter.filterModel,
368
393
  onFilterModelChange: gridFilter.onFilterModelChange,
369
394
  quickFilter: true,
395
+ disableColumnSorting: true,
370
396
  noRowsLabel: "No sequences match these filters",
371
397
  hideFooter: true
372
398
  }),
373
- footer,
374
- truncatedNotice
399
+ footer
375
400
  ]
376
401
  });
377
402
  }
@@ -381,7 +406,7 @@ const SEQUENCE_FILTER_OPTIONS = {
381
406
  anchor: '#sequences'
382
407
  }),
383
408
  HeaderProps: {
384
- action: sequences.status === 'ready' && sequences.data.length ? newButton : undefined
409
+ action: none || sequences.status === 'loading' ? undefined : newButton
385
410
  },
386
411
  contentGutterX: true,
387
412
  contentGutterY: true,